diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/MigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/MigrationHelper.groovy new file mode 100644 index 00000000000..ad419814b99 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/MigrationHelper.groovy @@ -0,0 +1,610 @@ +package com.netgrif.application.engine.migration + +import com.netgrif.application.engine.configuration.properties.MigrationProperties +import com.netgrif.application.engine.importer.service.Importer +import com.netgrif.application.engine.migration.helpers.CaseMigrationHelper +import com.netgrif.application.engine.migration.helpers.PetriNetMigrationHelper +import com.netgrif.application.engine.migration.helpers.TaskMigrationHelper +import com.netgrif.application.engine.migration.model.MigrationError +import com.netgrif.application.engine.migration.model.MigrationErrorPolicy +import com.netgrif.application.engine.objects.petrinet.domain.I18nString +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet +import com.netgrif.application.engine.objects.petrinet.domain.events.Event +import com.netgrif.application.engine.objects.petrinet.domain.events.EventType +import com.netgrif.application.engine.objects.petrinet.domain.roles.ProcessRole +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.TaskPair +import com.querydsl.core.types.Predicate +import groovy.util.logging.Slf4j +import org.bson.types.ObjectId +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.Resource +import org.springframework.stereotype.Component +/** + * Helper class for migrating cases, tasks and Petri net models. + * Provides convenience methods for updating existing data and models during system migrations. + * This class delegates migration operations to specialized helper classes for cases, tasks, and Petri nets. + */ +@Slf4j +@Component +class MigrationHelper { + + /** + * Helper for case-related migration operations including updating, iterating, and indexing cases. + */ + @Autowired + private CaseMigrationHelper caseMigrationHelper + + /** + * Helper for task-related migration operations including updating, iterating, and managing task permissions. + */ + @Autowired + private TaskMigrationHelper taskMigrationHelper + + /** + * Helper for Petri net model migration operations including updating models, roles, and data sets. + */ + @Autowired + private PetriNetMigrationHelper petriNetMigrationHelper + + /** + * Configuration properties for migration operations. + * Contains settings such as default error policy and other migration-related configuration. + */ + @Autowired + private MigrationProperties migrationProperties + + /** + * Thread-local storage for the current migration error policy. + * Allows different threads to maintain their own error handling policies during migration operations. + * If not set, defaults to the policy specified in migrationProperties. + */ + private final ThreadLocal currentErrorPolicy = new ThreadLocal<>() + + /** + * Closure for updating role events between existing and reimported Petri net models. + * This closure synchronizes role-related events from the reimported model to the existing model, + * ensuring that role event configurations are properly migrated during process updates. + * @param existing The current Petri Net model that will be updated with new role events + * @param reimported The newly imported Petri Net model containing updated role event definitions + * @return Updated Petri Net model with synchronized role events + */ + Closure updateRoleEvents = { PetriNet existing, PetriNet reimported -> + petriNetMigrationHelper.updateRoleEvents(existing, reimported) + } + + /** + * Returns the Importer service instance used for importing and processing Petri net models. + * This method delegates to the PetriNetMigrationHelper to retrieve the importer. + * @return Importer service instance + */ + private Importer getImporter() { + return petriNetMigrationHelper.getImporter() + } + + /** + * Retrieves the current error policy for migration operations. + * If no policy is set in the thread-local storage, returns the default policy from migration properties. + * + * @return the current {@link MigrationErrorPolicy} or the default policy if none is set + */ + MigrationErrorPolicy getCurrentErrorPolicy() { + return currentErrorPolicy.get() ?: MigrationErrorPolicy.defaultErrorPolicy(migrationProperties.errorPolicy) + } + + /** + * Executes the provided closure with a specific error policy, then restores the previous policy. + * This method allows temporary override of the error handling policy for a specific migration operation. + * The previous policy is automatically restored after the closure execution, even if an exception occurs. + * + * @param policy the {@link MigrationErrorPolicy} to use during the closure execution + * @param code the closure containing migration code to execute with the specified error policy + */ + void withErrorPolicy(MigrationErrorPolicy policy, Closure code) { + MigrationErrorPolicy previous = currentErrorPolicy.get() + currentErrorPolicy.set(policy) + try { + code.call() + } finally { + if (previous) { + currentErrorPolicy.set(previous) + } else { + currentErrorPolicy.remove() + } + } + } + + /** + * Updates all cases filtered by filter Predicate. Update closure is called on each filtered case. + * @param update Instance of Closure, which should contain code that will be executed for every Case matched by filter + * @param filter Instance of Predicate, to filter which cases should be updated + */ + void updateCases(Closure update, Predicate filter) { + log.debug("updateCases called with filter: {}", filter) + caseMigrationHelper.updateCases(update, filter, getCurrentErrorPolicy()) + } + + /** + * Iterates all cases filtered by filter Predicate. Update closure is called on each filtered case. PageProcessed closure is called after each page iteration. + * @param update Instance of Closure, which should contain code that will be executed for every Case matched by filter (changes made to Case will not be saved automatically, for that use updateCases method) + * @param sleepFor Optional attribute to set sleep time (in milliseconds) to sleep for after each iterated page. Default 0ms + * @param filter Instance of Predicate, to filter which cases should be iterated + */ + void iterateCases(Closure update, Closure pageProcessed = null, + long sleepFor = 0, Predicate filter) { + log.debug("iterateCases called with filter: {}, sleepFor: {}", filter, sleepFor) + caseMigrationHelper.iterateCases(update, pageProcessed, sleepFor, filter, getCurrentErrorPolicy()) + } + + /** + * Updates all cases of a given process. + * @param update Instance of Closure, which should contain code that will be executed for every Case matched by filter + * @param processIdentifier identifier of PetriNet, to filter which cases should be updated + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateCasesCursor(Closure update, String processIdentifier, int pageSize = 100) { + log.debug("updateCasesCursor called with processIdentifier: {}, pageSize: {}", processIdentifier, pageSize) + caseMigrationHelper.updateCasesCursor(update, processIdentifier, pageSize, getCurrentErrorPolicy()) + } + + + /** + * Updates all cases of a given process identified by ObjectId. + * @param update Instance of Closure, which should contain code that will be executed for every Case matched by filter + * @param petriNetObjectId ObjectId of PetriNet, to filter which cases should be updated + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateCasesCursor(Closure update, ObjectId petriNetObjectId, int pageSize = 100) { + log.debug("updateCasesCursor called with petriNetObjectId: {}, pageSize: {}", petriNetObjectId, pageSize) + caseMigrationHelper.updateCasesCursor(update, petriNetObjectId, pageSize, getCurrentErrorPolicy()) + } + + /** + * Update all cases. + * @param update Instance of Closure, which should contain code that will be executed for every Case + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateAllCasesCursor(Closure update, int pageSize = 100) { + log.debug("updateAllCasesCursor called with pageSize: {}", pageSize) + caseMigrationHelper.updateAllCasesCursor(update, pageSize, getCurrentErrorPolicy()) + } + + /** + * Updates all tasks filtered by filter Predicate. Update closure is called on each filtered task. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter + * @param filter Instance of Predicate, to filter which tasks should be updated + */ + void updateTasks(Closure update, Predicate filter) { + log.debug("updateTasks called with filter: {}", filter) + taskMigrationHelper.updateTasks(update, filter, getCurrentErrorPolicy()) + } + + /** + * Iterates all tasks filtered by filter Predicate. Update closure is called on each filtered task. PageProcessed closure is called after each page iteration. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter (changes made to Task will not be saved automatically, for that use updateCases method) + * @param sleepFor Optional attribute to set sleep time (in milliseconds) to sleep for after each iterated page. Default 0ms + * @param filter Instance of Predicate, to filter which tasks should be iterated + */ + void iterateTasks(Closure update, Closure pageProcessed = null, long sleepFor = 0, Predicate filter) { + log.debug("iterateTasks called with filter: {}, sleepFor: {}", filter, sleepFor) + taskMigrationHelper.iterateTasks(update, pageProcessed, sleepFor, filter, getCurrentErrorPolicy()) + } + + /** + * Updates all tasks of a given process. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter + * @param processIdentifier identifier of PetriNet, to filter which tasks should be updated + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateTasksCursor(Closure update, String processIdentifier, int pageSize = 100) { + log.debug("updateTasksCursor called with processIdentifier: {}, pageSize: {}", processIdentifier, pageSize) + taskMigrationHelper.updateTasksCursor(update, processIdentifier, pageSize, getCurrentErrorPolicy()) + } + + /** + * Updates specific tasks of a given process. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter + * @param processIdentifier identifier of PetriNet, to filter which tasks should be updated + * @param transitionIds List of transition IDs to limit filter to specific transitions of given processIdentifier + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateSpecificTasksCursor(Closure update, String processIdentifier, List transitionIds, int pageSize = 100) { + log.debug("updateSpecificTasksCursor called with processIdentifier: {}, transitionIds: {}, pageSize: {}", processIdentifier, transitionIds, pageSize) + taskMigrationHelper.updateSpecificTasksCursor(update, processIdentifier, transitionIds, pageSize, getCurrentErrorPolicy()) + } + + /** + * Update all tasks. + * @param update Instance of Closure, which should contain code that will be executed for every Task + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateAllTasksCursor(Closure update, int pageSize = 100) { + log.debug("updateAllTasksCursor called with pageSize: {}", pageSize) + taskMigrationHelper.updateAllTasksCursor(update, pageSize, getCurrentErrorPolicy()) + } + + /** + * Updates existing Petri Net model with new values. New process roles are ignored! New roles in existing user type fields will be ignored! + * @param identifier Identifier of Petri Net model that is being updated + * @param resource Resource object with new version of Petri Net model + */ + void updateNetIgnoreRoles(String identifier, Resource resource, List> customUpdates = null) { + log.debug("updateNetIgnoreRoles called with identifier: {}, resource: {}", identifier, resource) + petriNetMigrationHelper.updateNetIgnoreRoles(identifier, resource, customUpdates) + } + + /** + * Updates existing Petri Net model with new values. New process roles are ignored! New roles in existing user type fields will be ignored! + * @param identifier Identifier of Petri Net model that is being updated + * @param fileName File name of new version of Petri Net model + */ + void updateNetIgnoreRoles(String identifier, String fileName, List> customUpdates = null) { + log.debug("updateNetIgnoreRoles called with identifier: {}, fileName: {}", identifier, fileName) + petriNetMigrationHelper.updateNetIgnoreRoles(identifier, fileName, customUpdates) + } + + /** + * Updates existing Petri Net model with new values. New process roles are ignored! New roles in existing user type fields will be ignored! + * @param currentNet Current Petri Net object that will be updated + * @param reimported New version of Petri Net object, its values will be applied to currentNet + */ + void updateNetIgnoreRoles(PetriNet currentNet, PetriNet reimported, List> customUpdates) { + log.debug("updateNetIgnoreRoles called with currentNet: {}, reimported: {}", currentNet?.identifier, reimported?.identifier) + petriNetMigrationHelper.updateNetIgnoreRoles(currentNet, reimported, customUpdates) + } + + /** + * Replaces role permissions on transition with provided map e.g. ["roleId": ["perform": true]] + * @param net Instance of Petri Net in which role on transition will be updated + * @param transitionId Transition ID of updated transition + * @param role ProcessRole that will be updated on transition + * @param permissions New role permissions on transition + */ + void updateTransitionRoles(PetriNet net, String transitionId, ProcessRole role, Map permissions) { + log.debug("updateTransitionRoles called with net: {}, transitionId: {}, role: {}, permissions: {}", net?.identifier, transitionId, role?.stringId, permissions) + petriNetMigrationHelper.updateTransitionRoles(net, transitionId, role, permissions) + } + + /** + * Replaces role permissions on transition with provided map e.g. ["roleId": ["perform": true]] + * @param net Instance of Petri Net in which role on transition will be updated + * @param transitionId Transition ID of updated transition + * @param roleImportId ID of a role that will be updated on transition + * @param permissions New role permissions on transition + */ + void updateTransitionRoles(PetriNet net, String transitionId, String roleImportId, Map permissions) { + log.debug("updateTransitionRoles called with net: {}, transitionId: {}, roleImportId: {}, permissions: {}", net?.identifier, transitionId, roleImportId, permissions) + petriNetMigrationHelper.updateTransitionRoles(net, transitionId, roleImportId, permissions) + } + + /** + * Replaces role permissions on transition with provided map e.g. ["roleId": ["perform": true]] + * @param transitionId Transition ID of updated transition + * @param roleImportId ID of a role that will be updated on transition + * @param permissions New role permissions on transition + */ + Closure updateTransitionRolesClosure(String transitionId, String roleImportId, Map permissions) { + log.debug("updateTransitionRolesClosure called with transitionId: {}, roleImportId: {}, permissions: {}", transitionId, roleImportId, permissions) + petriNetMigrationHelper.updateTransitionRolesClosure(transitionId, roleImportId, permissions) + } + + /** + * Updates data set of existing Petri Net model with new values. + * @param identifier Identifier of Petri Net model that is being updated + * @param fileName File name of new version of Petri Net model + */ + void updateDataSet(String identifier, String fileName, Closure customUpdate = null) { + log.debug("updateDataSet called with identifier: {}, fileName: {}", identifier, fileName) + petriNetMigrationHelper.updateDataSet(identifier, fileName, customUpdate) + } + + /** + * Create new role in existing Petri Net model. + * @param identifier Identifier of Petri Net model in which the Process Role will be created + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + def createRoleInNet(String identifier, String id, String title, Map events = [:]) { + log.debug("createRoleInNet called with identifier: {}, id: {}, title: {}", identifier, id, title) + return petriNetMigrationHelper.createRoleInNet(identifier, id, title, events) + } + + /** + * Create new role in existing Petri Net model. + * @param identifier Identifier of Petri Net model in which the Process Role will be created + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + def createRoleInNet(String identifier, String id, I18nString title, Map events = [:]) { + log.debug("createRoleInNet called with identifier: {}, id: {}, title: {}", identifier, id, title) + return petriNetMigrationHelper.createRoleInNet(identifier, id, title, events) + } + + /** + * Creates new global role + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + def createGlobalRole(String id, String title, Map events = [:]) { + log.debug("createGlobalRole called with id: {}, title: {}", id, title) + return petriNetMigrationHelper.createGlobalRole(id, title, events) + } + + /** + * Creates new global role + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + def createGlobalRole(String id, I18nString title, Map events = [:]) { + log.debug("createGlobalRole called with id: {}, title: {}", id, title) + return petriNetMigrationHelper.createGlobalRole(id, title, events) + } + + /** + * Reloads tasks of provided case via TaskService, + * handles useCase.petriNet internally + * @param useCase Instance of Case for which tasks will be reloaded + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void reloadTasks(Case useCase, PetriNet net) { + log.debug("reloadTasks called with useCase: {}, net: {}", useCase?.stringId, net?.identifier) + taskMigrationHelper.reloadTasks(useCase, net) + } + + /** + * Indexes provided case in elasticsearch + * handles useCase.petriNet internally + * @param useCase Instance of Case that will be indexed into elasticsearch index + */ + void elasticIndex(Case useCase) { + log.debug("elasticIndex called with useCase: {}", useCase?.stringId) + caseMigrationHelper.elasticIndex(useCase, getCurrentErrorPolicy()) + } + + /** + * Indexes provided task in elasticsearch + * @param task Instance of Task that will be indexed into elasticsearch index + */ + void elasticTaskIndex(Task task) { + log.debug("elasticTaskIndex called with task: {}", task?.stringId) + taskMigrationHelper.elasticTaskIndex(task, getCurrentErrorPolicy()) + } + + /** + * Adds role with permissions to existing tasks of net + * @param role ProcessRole that will be added to transitions + * @param net Instance of Petri Net of updated transitions + * @param transitionIds List of transition IDs the role will be added to + * @param permissions Map of permissions for the role + */ + void addRoleToExistingTasks(ProcessRole role, PetriNet net, List transitionIds, Map permissions) { + log.debug("addRoleToExistingTasks called with role: {}, net: {}, transitionIds: {}, permissions: {}", role?.stringId, net?.identifier, transitionIds, permissions) + taskMigrationHelper.addRoleToExistingTasks(role, net, transitionIds, permissions) + } + + /** + * Sets petriNet object in case instance + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + static void setPetriNet(Case useCase, PetriNet net) { + PetriNetMigrationHelper.setPetriNet(useCase, net) + } + + /** + * Delete given data fields from useCase + * @param useCase Instance of Case + * @param toDelete List of field IDs that will be deleted from useCase + */ + static void deleteDataFields(Case useCase, Set toDelete) { + CaseMigrationHelper.deleteDataFields(useCase, toDelete) + } + + /** + * Changes value of given data fields from number to text + * @param useCase Instance of Case + * @param toChange List of field IDs for value change + */ + static void changeDataFieldsValueFromNumberToText(Case useCase, Set toChange) { + CaseMigrationHelper.changeDataFieldsValueFromNumberToText(useCase, toChange) + } + + /** + * Changes value of given data fields from text to number + * @param useCase Instance of Case + * @param toChange List of field IDs for value change + */ + void changeDataFieldsValueFromTextToNumber(Case useCase, Set toChange) { + caseMigrationHelper.changeDataFieldsValueFromTextToNumber(useCase, toChange, getCurrentErrorPolicy()) + } + + /** + * Adds new data fields with their init value into useCase + * @param useCase Instance of Case + * @param toAdd Map + */ + static void addTextDataFields(Case useCase, Map toAdd) { + CaseMigrationHelper.addTextDataFields(useCase, toAdd) + } + + /** + * Changes value of given data fields from enumeration to multichoice + * @param useCase Instance of Case + * @param toChange List of field IDs for value change + */ + static void changeDataFieldsValueFromEnumerationToMultichoice(Case useCase, Set toChange) { + CaseMigrationHelper.changeDataFieldsValueFromEnumerationToMultichoice(useCase, toChange) + } + + /** + * Adds new choices into enumeration or multichoice field + * @param useCase Instance of Case + * @param toAdd Map + */ + static void addChoices(Case useCase, Map> toAdd) { + CaseMigrationHelper.addChoices(useCase, toAdd) + } + + /** + * Removes choices from enumeration or multichoice field + * @param useCase Instance of Case + * @param toAdd Map + */ + static void removeChoices(Case useCase, Map> toRemove) { + CaseMigrationHelper.removeChoices(useCase, toRemove) + } + + /** + * Changes value from FileFieldValue to FileListFieldValue + * @param useCase Instance of Case + * @param fieldId Field ID for value change + */ + static void changeFileFieldToFileList(Case useCase, String fieldId) { + CaseMigrationHelper.changeFileFieldToFileList(useCase, fieldId) + } + + /** + * Helper method used in updateNetIgnoreRoles method, it sorts PetriNet dataSet alphabetically + * @param petriNet Instance of Petri Net + */ + static void resolveDataOrder(PetriNet petriNet) { + PetriNetMigrationHelper.resolveDataOrder(petriNet) + } + + /** + * Update dataField and dataRef components of given case + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + static void updateCaseComponents(Case useCase, PetriNet net) { + CaseMigrationHelper.updateCaseComponents(useCase, net) + } + + /** + * Method that collects all dataRef components of given PetriNet. Should be used in updateCases method, when a new dataRef component is added into PetriNet. + * @param net Instance of PetriNet + */ + static Map> createDataRefComponentsMap(PetriNet net) { + PetriNetMigrationHelper.createDataRefComponentsMap(net) + } + + /** + * Method that collects all dataField components of given PetriNet. Should be used in updateCases method, when a new dataField component is added into PetriNet. + * @param net Instance of PetriNet + */ + static Map createComponentsMap(PetriNet net) { + PetriNetMigrationHelper.createComponentsMap(net) + } + + /** + * Updates case permissions from PetriNet + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void updateCasePermissionsFromNet(Case useCase, PetriNet net, boolean updateTasks = false) { + log.debug("updateCasePermissionsFromNet called with useCase: {}, net: {}, updateTasks: {}", useCase?.stringId, net?.identifier, updateTasks) + caseMigrationHelper.updateCasePermissionsFromNet(useCase, net, updateTasks, getCurrentErrorPolicy()) + } + + /** + * Updates permissions on existing tasks filtered by relevantTransitionIds + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + * @param relevantTransitionIds List of transition IDs for permissions update + */ + void updateTasksPermissions(Case useCase, PetriNet net, List relevantTransitionIds) { + log.debug("updateTasksPermissions called with useCase: {}, net: {}, relevantTransitionIds: {}", useCase?.stringId, net?.identifier, relevantTransitionIds) + taskMigrationHelper.updateTasksPermissions(useCase, net, relevantTransitionIds, getCurrentErrorPolicy()) + } + + /** + * Updates permissions on existing task + * @param useCase Instance of Case + * @param taskPair TaskPair object of updated Task + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void updateTaskPermissions(Case useCase, TaskPair taskPair, PetriNet net) { + log.debug("updateTaskPermissions called with useCase: {}, taskPair: {}, net: {}", useCase?.stringId, taskPair?.task?.toString(), net?.identifier) + taskMigrationHelper.updateTaskPermissions(useCase, taskPair, net, getCurrentErrorPolicy()) + } + + /** + * Changes PetriNet reference in useCase + * @param useCase Instance of Case + * @param newNet Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void migratePetriNet(Case useCase, PetriNet newNet) { + caseMigrationHelper.migratePetriNet(useCase, newNet) + } + + /** + * Removes a case from the system. + * This operation delegates to the CaseMigrationHelper to perform the actual deletion, + * respecting the current error policy for handling any issues that may occur during removal. + * + * @param useCase Instance of Case to be removed from the system + */ + void removeCase(Case useCase) { + caseMigrationHelper.removeCase(useCase, getCurrentErrorPolicy()) + } + + /** + * Returns cached migration errors without clearing them. + * + * @return immutable snapshot of cached migration errors + */ + List getErrors() { + List errors = [] + errors.addAll(caseMigrationHelper.getErrors()) + errors.addAll(taskMigrationHelper.getErrors()) + errors.addAll(petriNetMigrationHelper.getErrors()) + return Collections.unmodifiableList(errors) + } + + /** + * Returns cached migration errors and clears the cache. + * + * @return cached migration errors collected since the last clear/pop + */ + List popErrors() { + List errors = [] + errors.addAll(caseMigrationHelper.popErrors()) + errors.addAll(taskMigrationHelper.popErrors()) + errors.addAll(petriNetMigrationHelper.popErrors()) + return errors + } + + /** + * Clears cached migration errors. + */ + void clearErrors() { + caseMigrationHelper.clearErrors() + taskMigrationHelper.clearErrors() + petriNetMigrationHelper.clearErrors() + } + + /** + * Indicates whether any migration errors were cached. + * + * @return true if at least one error is cached + */ + boolean hasErrors() { + return caseMigrationHelper.hasErrors() || taskMigrationHelper.hasErrors() || petriNetMigrationHelper.hasErrors() + } + + /** + * Runs migration code with a clean error cache and returns errors collected during execution. + * + * @param migrationCode migration logic to execute + * @return errors collected during migrationCode execution + */ + List collectErrors(Closure migrationCode) { + clearErrors() + try { + migrationCode.call() + } finally { + return popErrors() + } + } +} \ No newline at end of file diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/AbstractMigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/AbstractMigrationHelper.groovy new file mode 100644 index 00000000000..427f6483e37 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/AbstractMigrationHelper.groovy @@ -0,0 +1,375 @@ +package com.netgrif.application.engine.migration.helpers + +import com.mongodb.BulkWriteException +import com.mongodb.bulk.BulkWriteResult +import com.netgrif.application.engine.configuration.properties.MigrationProperties +import com.netgrif.application.engine.migration.model.MigrationError +import com.netgrif.application.engine.migration.model.MigrationErrorHandlingMode +import com.netgrif.application.engine.migration.model.MigrationErrorPolicy +import com.netgrif.application.engine.migration.throwable.MigrationErrorException +import com.netgrif.application.engine.objects.workflow.domain.Case +import com.netgrif.application.engine.utils.MongodbUtils +import com.querydsl.core.types.Predicate +import groovy.util.logging.Slf4j +import org.springframework.data.mongodb.core.BulkOperations +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.util.CloseableIterator + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.stream.Stream + +/** + * AbstractMigrationHelper is an abstract utility class to facilitate the bulk migration of + * MongoDB documents. The class provides mechanisms for iterating over documents, preparing + * bulk migration operations, and executing those operations efficiently using Spring Data MongoDB's + * BulkOperations. It is generic and requires the subtype (document type) to be specified. + * + * @param The type of documents this helper will operate on. + */ +@Slf4j +abstract class AbstractMigrationHelper { + + /** + * The type of the documents this helper is operating on. + * It is expected to be provided by subclasses, as the class itself is generic and requires + * specific document type initialization to perform the corresponding operations. + */ + protected final Class type + + /** + * The {@link MongoTemplate} used for interacting with the MongoDB database. + * This is the core dependency of the helper class, allowing it to execute queries, + * bulk operations, and other database operations on the specified document type. + */ + protected final MongoTemplate mongoTemplate + + /** + * Configuration properties for migration operations, providing settings such as error handling policies, + * page sizes, and other migration-related parameters used throughout the migration process. + */ + protected final MigrationProperties migrationProperties + + /** + * A thread-safe list of migration errors that occurred during the migration process. + * This list stores all errors encountered while processing documents, allowing the migration + * to continue execution while collecting errors for later review and reporting. + * The list uses {@link CopyOnWriteArrayList} to ensure thread-safety during concurrent + * migration operations. + */ + private final List migrationErrors + + /** + * Constructs a new AbstractMigrationHelper with the specified MongoTemplate. + * + * @param mongoTemplate the {@link MongoTemplate} to use for interacting with MongoDB + */ + AbstractMigrationHelper(Class type, + MongoTemplate mongoTemplate, + MigrationProperties migrationProperties) { + this.type = type + this.mongoTemplate = mongoTemplate + this.migrationProperties = migrationProperties + this.migrationErrors = new CopyOnWriteArrayList<>() + } + + /** + * Returns the page size that should be used for iterating over documents. + * + * @return the number of documents per page + */ + abstract int getPageSize() + + /** + * Prepares bulk operations on a single document. + * This method must be implemented by subclasses to define the specific bulk operations + * to perform on each document. + * + * @param document the document to process + * @param update the Closure defining the update operation + * @param bulkOperations the {@link BulkOperations} instance to add operations to + */ + abstract void prepareOperations(T document, Closure update, BulkOperations bulkOperations) + + /** + * Resolves and extracts the unique identifier from the given document. + * This method must be implemented by subclasses to provide the logic for determining + * the document's ID, which is used for error reporting and logging during migration operations. + * The implementation should handle the specific ID field structure of the document type. + * + * @param document the document from which to resolve the identifier + * @return the unique identifier of the document as a String, or null if the ID cannot be resolved + */ + abstract String resolveId(T document) + + /** + * Caches a migration error into the thread-safe error list for later retrieval and reporting. + * This method is typically called when an error occurs during document migration operations, + * allowing the migration process to continue while collecting all errors for review. + * + * @param helper the name or identifier of the migration helper where the error occurred + * @param operation the specific operation being performed when the error occurred + * @param entityType the type of entity (document type) being migrated + * @param entityId the unique identifier of the entity that caused the error + * @param message a descriptive message explaining the error + * @param cause the optional {@link Throwable} that caused the error; defaults to null + */ + void cacheError(String helper, + String operation, + Class entityType, + String entityId, + String message, + Throwable cause = null) { + migrationErrors.add(MigrationError.of(helper, operation, entityType, entityId, message, cause)) + } + + /** + * Returns an unmodifiable view of all migration errors collected during the migration process. + * The returned list is a snapshot of the current errors and will not reflect any subsequent + * changes to the error cache. + * + * @return an unmodifiable {@link List} of {@link MigrationError} objects + */ + List getErrors() { + return Collections.unmodifiableList(new ArrayList<>(migrationErrors)) + } + + /** + * Retrieves all cached migration errors from this helper instance and clears the error cache. + * This method is useful for retrieving errors for reporting purposes while simultaneously + * resetting the error cache for a new migration operation. + * + * @return a {@link List} of all {@link MigrationError} objects that were cached + */ + List popErrors() { + synchronized (migrationErrors) { + List errors = new ArrayList<>(migrationErrors) + migrationErrors.clear() + return errors + } + } + +/** + * Clears all cached migration errors from this helper instance. + * This method should be called to reset this helper's error cache before starting a new migration + * operation or after errors have been processed and reported. + */ + void clearErrors() { + migrationErrors.clear() + } + + /** + * Checks whether any migration errors have been cached by this helper instance. + * This method is useful for quickly determining if any errors occurred during + * the migration process without retrieving the full error list. + * + * @return {@code true} if one or more errors are cached, {@code false} otherwise + */ + boolean hasErrors() { + return !migrationErrors.isEmpty() + } + + /** + * Handles the execution of bulk operations. + * It executes the given {@link BulkOperations} instance and logs the results or any errors. + * + * @param bulkOps the bulk operations to execute + */ + void handleBulkOps(BulkOperations bulkOps, Class type) { + try { + BulkWriteResult bulkWriteResult = bulkOps.execute() + log.debug("Processed bulk write of ${bulkWriteResult.modifiedCount}") + } catch (BulkWriteException e) { + log.error("Failed to write bulk operation", e) + e.getWriteErrors().forEach { + String message = "Error writing document with ID ${it.toString()}. Cause: ${it.getMessage()}" + log.error(message) + cacheError(this.class.simpleName, "bulkWrite", type, it.toString(), message, e) + } + throw e + } + } + + /** + Iterates over the documents in the collection, applies updates, and executes bulk operations. * The iteration is paginated based on the provided or default page size, and supports customizable + * bulk operation processing and optional sleep intervals between pages. + * + * @param update a {@link Closure} defining the update to apply to documents + * @param processOperations an optional {@link Closure} to process bulk operations; defaults + * to null + * @param query an optional MongoDB {@link Query} to filter documents; defaults to an empty query + * @param sleepFor the optional number of milliseconds to sleep between processing pages; defaults to 0 + * @param pageSize the size of each page (number of documents); defaults to the result of {@link #getPageSize()} + */ + void iterate(Closure update, Closure processOperations = null, + Query query = new Query(), long sleepFor = 0, int pageSize = getPageSize(), + MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + if (pageSize <= 0) { + throw new IllegalArgumentException("pageSize must be > 0") + } + Closure effectiveProcessOperations = processOperations ?: { BulkOperations bulkOperations, Class entityType -> + handleBulkOps(bulkOperations, entityType) + } + + long count = mongoTemplate.count(query, type) + if (count > 0) { + long numOfPages = Math.ceil(count / pageSize) as long + log.info("Processing ${type.getSimpleName()} documents with filter ${query.toString()}: $numOfPages pages") + + long page = 1, currentBatchSize = 0, currentBulkOpsSize = 0 + query.cursorBatchSize(pageSize) + BulkOperations bulkOps = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, type) + + try (Stream cursorStream = mongoTemplate.stream(query, type)) { + Iterator cursor = cursorStream.iterator() + while (cursor.hasNext()) { + T document = cursor.next() + + try { + prepareOperations(document, update, bulkOps) + currentBulkOpsSize++ + } catch (Exception e) { + String entityId = resolveId(document) + String message = "Failed to prepare migration operation for ${type.simpleName} ${entityId}" + log.error(message, e) + handleMigrationError(errorPolicy, "iterate", type, entityId, message, e) + } + + if (++currentBatchSize == pageSize as long || !cursor.hasNext()) { + log.debug("Processed ${type.getSimpleName()} document page {} / {}", page, numOfPages) + + try { + if (currentBulkOpsSize > 0) { + effectiveProcessOperations(bulkOps, type) + } + } catch (Exception e) { + String message = "Failed to process ${type.simpleName} bulk operations on page ${page}" + log.error(message, e) + handleMigrationError(errorPolicy, "bulkWrite", type, null, message, e) + } + + bulkOps = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, type) + currentBatchSize = 0 + currentBulkOpsSize = 0 + page++ + if (sleepFor > 0) { + log.debug("Pausing migration for ${sleepFor} milliseconds") + sleep(sleepFor) + } + } + } + } catch (Exception e) { + if (e instanceof MigrationErrorException) { + throw e + } + String message = "Failed to iterate ${type.simpleName} documents with filter ${query}" + log.error(message, e) + handleMigrationError(errorPolicy, "iterate", type, null, message, e) + throw e + } finally { + finishMigrationErrorPolicy(errorPolicy) + } + } + } + + /** + * Returns the default migration error policy configured in the application properties. + * This policy determines how errors should be handled during migration operations, + * including whether to cache errors, throw exceptions immediately, or continue processing. + * + * @return a {@link MigrationErrorPolicy} instance based on the configured migration properties + */ + MigrationErrorPolicy defaultErrorPolicy() { + return MigrationErrorPolicy.defaultErrorPolicy(migrationProperties.errorPolicy) + } + + /** + * Converts a QueryDSL {@link Predicate} to a MongoDB {@link Query}. + * This method delegates to the {@link MongodbUtils} utility to perform the conversion, + * using the current MongoTemplate and document type. + * + * @param predicate the QueryDSL predicate to convert + * @return a MongoDB Query object representing the predicate + */ + protected Query toQuery(Predicate predicate) { + return MongodbUtils.toQuery(mongoTemplate, type, predicate) + } + + /** + * Handles migration errors according to the specified error policy. + * This method implements different error handling strategies based on the policy mode, + * including caching errors, throwing exceptions immediately, throwing after reaching an error limit, + * or continuing processing to throw after all operations complete. + * + * @param policy the {@link MigrationErrorPolicy} defining how to handle the error + * @param operation the name of the operation being performed when the error occurred + * @param type the class type of the entity being migrated + * @param entityId the unique identifier of the entity that caused the error, or null if not applicable + * @param message a descriptive message explaining the error + * @param cause the optional {@link Throwable} that caused the error; defaults to null + * @throws MigrationErrorException if the error policy requires throwing an exception + */ + protected void handleMigrationError(MigrationErrorPolicy policy, String operation, Class type, String entityId, + String message, Throwable cause = null) { + if (policy.cacheErrors) { + cacheError(this.class.simpleName, operation, type, entityId, message, cause) + } + + switch (policy.mode) { + case MigrationErrorHandlingMode.THROW_IMMEDIATELY: + throwError(policy, message, cause) + break + case MigrationErrorHandlingMode.THROW_AFTER_LIMIT: + if (policy.maxErrors > 0 && getErrors().size() >= policy.maxErrors) { + throwError(policy, "Migration failed after reaching error limit ${policy.maxErrors}", cause) + } + break + case MigrationErrorHandlingMode.CONTINUE: + break + case MigrationErrorHandlingMode.THROW_AFTER_PROCESSING: + break + } + } + + /** + * Throws an exception based on the specified error policy and error details. + * If the policy specifies throwing the original exception and the cause is a RuntimeException, + * the original exception is re-thrown. Otherwise, a new {@link MigrationErrorException} is thrown + * containing the message, all cached errors, and the original cause. + * + * @param policy the {@link MigrationErrorPolicy} defining how to throw the error + * @param message a descriptive message explaining the error + * @param cause the optional {@link Throwable} that caused the error; defaults to null + * @throws RuntimeException or {@link MigrationErrorException} depending on the policy and cause + */ + protected void throwError(MigrationErrorPolicy policy, String message, Throwable cause = null) { + if (policy.throwOriginal && cause instanceof RuntimeException) { + throw cause + } + + throw new MigrationErrorException( + message, + getErrors(), + cause + ) + } + + /** + * Finalizes the migration error policy after processing is complete. + * If the error policy mode is {@link MigrationErrorHandlingMode#THROW_AFTER_PROCESSING} + * and errors were collected during processing, throws a {@link MigrationErrorException} + * containing all cached errors. This method should be called in the finally block + * of migration operations to ensure proper error handling. + * + * @param policy the {@link MigrationErrorPolicy} defining the error handling behavior + * @throws MigrationErrorException if the policy requires throwing after processing and errors exist + */ + protected void finishMigrationErrorPolicy(MigrationErrorPolicy policy) { + if ((policy.mode == MigrationErrorHandlingMode.THROW_AFTER_PROCESSING || (policy.mode == MigrationErrorHandlingMode.THROW_AFTER_LIMIT && policy.maxErrors <= 0)) && hasErrors()) { + throw new MigrationErrorException( + "Migration finished with ${getErrors().size()} errors", + getErrors() + ) + } + } +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/CaseMigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/CaseMigrationHelper.groovy new file mode 100644 index 00000000000..0b51900340b --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/CaseMigrationHelper.groovy @@ -0,0 +1,470 @@ +package com.netgrif.application.engine.migration.helpers + +import com.mongodb.client.result.DeleteResult +import com.netgrif.application.engine.configuration.properties.MigrationProperties +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseMappingService +import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseService +import com.netgrif.application.engine.migration.model.MigrationErrorPolicy +import com.netgrif.application.engine.objects.petrinet.domain.I18nString +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet +import com.netgrif.application.engine.objects.petrinet.domain.dataset.FileFieldValue +import com.netgrif.application.engine.objects.petrinet.domain.dataset.FileListFieldValue +import com.netgrif.application.engine.objects.workflow.domain.Case +import com.netgrif.application.engine.objects.workflow.domain.DataField +import com.netgrif.application.engine.objects.workflow.domain.ProcessResourceId +import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService +import com.querydsl.core.types.Predicate +import groovy.util.logging.Slf4j +import org.bson.types.ObjectId +import org.springframework.data.mongodb.core.BulkOperations +import org.springframework.data.mongodb.core.FindAndReplaceOptions +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.stereotype.Component + +import java.time.LocalDateTime +import java.util.stream.Collectors + +/** + * Helper class for managing migrations of Case objects in the application. + * Provides methods for updating and iterating over case objects, filtered + * by specified conditions, and applying custom update logic using closures. + * + * This class extends {@link AbstractMigrationHelper} and utilizes MongoDB + * for operations on the data. + */ +@Slf4j +@Component +class CaseMigrationHelper extends AbstractMigrationHelper { + + /** + * Service for managing PetriNet operations. + */ + protected final IPetriNetService petriNetService + + /** + * Service for indexing and managing cases in Elasticsearch. + */ + protected final IElasticCaseService elasticCaseService + + /** + * Service for mapping Case objects to Elasticsearch documents. + */ + protected final IElasticCaseMappingService elasticCaseMappingService + + /** + * Helper for managing task migrations associated with cases. + */ + protected final TaskMigrationHelper taskMigrationHelper + + /** + * Constructs a CaseMigrationHelper instance with + * the provided MongoTemplate and migration configuration properties. + * + * @param mongoTemplate MongoTemplate to interact with MongoDB. + * @param migrationConfigurationProperties Properties for migration configuration, including cases. + */ + CaseMigrationHelper(MongoTemplate mongoTemplate, + MigrationProperties migrationProperties, + IPetriNetService petriNetService, + IElasticCaseService elasticCaseService, + IElasticCaseMappingService elasticCaseMappingService, + TaskMigrationHelper taskMigrationHelper) { + super(Case.class, mongoTemplate, migrationProperties) + this.petriNetService = petriNetService + this.elasticCaseService = elasticCaseService + this.elasticCaseMappingService = elasticCaseMappingService + this.taskMigrationHelper = taskMigrationHelper + } + + /** + * Retrieves the configured page size for batch processing of cases. + * + * @return the page size for case processing. + */ + @Override + int getPageSize() { + return migrationProperties.cases.pageSize + } + + /** + * Prepares bulk operations for updating a case. The provided update closure + * is executed to modify the case, and a replace operation is created. + * + * @param useCase The case object to update. + * @param update A closure containing the update logic to be applied to the case. + * @param bulkOperations BulkOperations instance used to queue updates for batch processing. + */ + @Override + void prepareOperations(Case useCase, Closure update, BulkOperations bulkOperations) { + log.debug("Updating case with ID ${useCase.stringId}") + update(useCase) + bulkOperations.replaceOne(Query.query(Criteria.where("_id").is(useCase.get_id())), useCase, FindAndReplaceOptions.options().upsert()) + } + + /** + * Resolves and retrieves the string representation of the ID for the given Case document. + * + * @param document The Case document whose ID should be resolved. + * @return The string representation of the case's ID. + */ + @Override + String resolveId(Case document) { + return document.getStringId() + } + + /** + * Updates all cases that match the given filter predicate. The update closure + * is executed for each matched case. + * + * @param update A closure containing the code to execute for each matching case. + * @param filter A QueryDSL Predicate object specifying the conditions to filter the cases. + */ + void updateCases(Closure update, Predicate filter, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Updating cases with filter ${filter.toString()} and update ${update.toString()}") + iterate(update, null, toQuery(filter), 0, getPageSize(), errorPolicy) + } + + /** + * Iterates over all cases that match the given filter predicate. The update closure + * is executed for each matched case, and the pageProcessed closure is called after each page. + * + * @param update A closure containing the code to execute for each matching case. + * @param pageProcessed A closure executed after processing each page. Defaults to null. + * @param sleepFor Optional sleep time (in milliseconds) between processing pages. Default is 0ms. + * @param filter A QueryDSL Predicate object specifying the conditions to filter the cases. + */ + void iterateCases(Closure update, Closure pageProcessed = null, long sleepFor = 0, + Predicate filter, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting iterateCases with filter: ${filter.toString()}, sleepFor: ${sleepFor}ms") + iterate(update, pageProcessed, toQuery(filter), sleepFor, getPageSize(), errorPolicy) + } + + /** + * Updates all cases of a specific process identified by its process identifier. + * + * @param update A closure containing the code to execute for each matching case. + * @param processIdentifier The identifier of the PetriNet process. + * @param pageSize Optional page size for processing cases. Default is 100. + */ + void updateCasesCursor(Closure update, String processIdentifier, int pageSize = 100, + MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateCasesCursor for processIdentifier: ${processIdentifier}, pageSize: ${pageSize}") + Query query = new Query(Criteria.where("processIdentifier").is(processIdentifier)) + iterate(update, null, query, 0, pageSize, errorPolicy) + } + + /** + * Updates all cases associated with a specific PetriNet identified by its ObjectId. + * The update closure is executed for each matching case. + * + * @param update A closure containing the code to execute for each matching case. + * @param petriNetObjectId The ObjectId of the PetriNet whose cases should be updated. + * @param pageSize Optional page size for processing cases. Default is 100. + * @param errorPolicy Optional error handling policy. Defaults to the default error policy. + */ + void updateCasesCursor(Closure update, ObjectId petriNetObjectId, int pageSize = 100, + MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateCasesCursor for petriNetObjectId: ${petriNetObjectId}, pageSize: ${pageSize}") + Query query = new Query(Criteria.where("petriNetObjectId").is(petriNetObjectId)) + iterate(update, null, query, 0, pageSize, errorPolicy) + } + + /** + * Updates all cases in the system. The update closure is executed for each case. + * + * @param update A closure containing the code to execute for each case. + * @param pageSize Optional page size for processing cases. Default is 100. + */ + void updateAllCasesCursor(Closure update, int pageSize = 100, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateAllCasesCursor with pageSize: ${pageSize}") + iterate(update, null, new Query(), 0, pageSize, errorPolicy) + } + + /** + * Indexes provided case in elasticsearch + * handles useCase.petriNet internally + * @param useCase Instance of Case that will be indexed into elasticsearch index + */ + void elasticIndex(Case useCase, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting elasticIndex for case: ${useCase.stringId}") + try { + PetriNetMigrationHelper.setPetriNet(useCase, petriNetService.get(useCase.petriNetObjectId)) + if (!useCase.petriNet) { + String message = "Failed to set petriNet for case $useCase.stringId" + log.error(message) + handleMigrationError(errorPolicy, "elasticIndex", type, useCase.stringId, message) + return + } + log.trace("Successfully set petriNet for case: ${useCase.stringId}") + elasticCaseService.indexNow(elasticCaseMappingService.transform(useCase)) + } catch (Exception ex) { + if (useCase.lastModified == null) { + log.warn("Creating new lastModified date for $useCase.stringId") + useCase.lastModified = LocalDateTime.now() + try { + elasticCaseService.indexNow(elasticCaseMappingService.transform(useCase)) + } catch (Exception retryEx) { + String message = "Failed to index $useCase.stringId after setting lastModified" + log.error(message, retryEx) + handleMigrationError(errorPolicy, "elasticIndex", type, useCase.stringId, message, retryEx) + } + } else { + String message = "Failed to index $useCase.stringId" + log.error(message, ex) + handleMigrationError(errorPolicy, "elasticIndex", type, useCase.stringId, message, ex) + } + } + } + + /** + * Delete given data fields from useCase + * @param useCase Instance of Case + * @param toDelete List of field IDs that will be deleted from useCase + */ + static void deleteDataFields(Case useCase, Set toDelete) { + log.debug("Starting deleteDataFields for case: ${useCase.stringId}, fields to delete: ${toDelete}") + toDelete.each { dataFieldID -> + log.trace("Removing data field: ${dataFieldID} from case: ${useCase.stringId}") + useCase.dataSet.remove(dataFieldID) + } + } + + /** + * Changes value of given data fields from number to text + * @param useCase Instance of Case + * @param toChange List of field IDs for value change + */ + static void changeDataFieldsValueFromNumberToText(Case useCase, Set toChange) { + log.debug("Starting changeDataFieldsValueFromNumberToText for case: ${useCase.stringId}, fields to change: ${toChange}") + toChange.each { dataFieldID -> + DataField dataField = useCase.dataSet[dataFieldID] + if (dataField?.value != null && dataField.value != "") { + double value = dataField.value as double + dataField.value = value as String + log.trace("Converted field ${dataFieldID} from number ${value} to text in case: ${useCase.stringId}") + } + } + } + + /** + * Changes value of given data fields from text to number + * @param useCase Instance of Case + * @param toChange List of field IDs for value change + */ + void changeDataFieldsValueFromTextToNumber(Case useCase, Set toChange, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting changeDataFieldsValueFromTextToNumber for case: ${useCase.stringId}, fields to change: ${toChange}") + toChange.each { dataFieldID -> + DataField dataField = useCase.dataSet[dataFieldID] + if (dataField?.value != null && dataField.value != "") { + try { + def originalValue = dataField.value + dataField.value = dataField.value as double + log.trace("Converted field ${dataFieldID} from text ${originalValue} to number in case: ${useCase.stringId}") + } catch (Exception e) { + def originalValue = dataField.value + dataField.value = null + String message = "[${useCase.stringId}] could not convert value ${originalValue} in field ${dataFieldID}" + log.error(message, e) + handleMigrationError(errorPolicy, "changeDataFieldsValueFromTextToNumber", type, useCase.stringId, message, e) + } + } + } + } + + /** + * Adds new data fields with their init value into useCase + * @param useCase Instance of Case + * @param toAdd Map + */ + static void addTextDataFields(Case useCase, Map toAdd) { + log.debug("Starting addTextDataFields for case: ${useCase.stringId}, fields to add: ${toAdd.keySet()}") + toAdd.each { dataFieldID, value -> + log.trace("Adding text data field ${dataFieldID} with value '${value}' to case: ${useCase.stringId}") + useCase.dataSet[dataFieldID] = new DataField(value) + } + } + + /** + * Changes value of given data fields from enumeration to multichoice + * @param useCase Instance of Case + * @param toChange List of field IDs for value change + */ + static void changeDataFieldsValueFromEnumerationToMultichoice(Case useCase, Set toChange) { + log.debug("Starting changeDataFieldsValueFromEnumerationToMultichoice for case: ${useCase.stringId}, fields to change: ${toChange}") + toChange.each { dataFieldID -> + DataField dataField = useCase.dataSet[dataFieldID] + if (!dataField) { + return + } + if (dataField.value && dataField.value != null) { + def value + if (dataField.value instanceof I18nString) { + value = dataField.value as I18nString + } else { + value = new I18nString(dataField.value as String) + } + + def newSet = new HashSet() + newSet.add(value) + dataField.value = newSet + log.trace("Converted field ${dataFieldID} from enumeration to multichoice in case: ${useCase.stringId}") + } + } + } + + /** + * Adds new choices into enumeration or multichoice field + * @param useCase Instance of Case + * @param toAdd Map + */ + static void addChoices(Case useCase, Map> toAdd) { + log.debug("Starting addChoices for case: ${useCase.stringId}, fields: ${toAdd.keySet()}") + toAdd.each { dataFieldID, newChoices -> + DataField dataField = useCase.dataSet[dataFieldID] + if (!dataField) { + return + } + if (dataField.choices == null) { + dataField.setChoices(new HashSet()) + } + + newChoices.each { + log.trace("Adding choice '${it}' to field ${dataFieldID} in case: ${useCase.stringId}") + dataField.choices.add(new I18nString(it)) + } + } + } + + /** + * Removes choices from enumeration or multichoice field + * @param useCase Instance of Case + * @param toAdd Map + */ + static void removeChoices(Case useCase, Map> toRemove) { + log.debug("Starting removeChoices for case: ${useCase.stringId}, fields: ${toRemove.keySet()}") + toRemove.each { dataFieldID, choicesToRemove -> + log.trace("Removing choices ${choicesToRemove} from field ${dataFieldID} in case: ${useCase.stringId}") + DataField dataField = useCase.dataSet[dataFieldID] + if (!dataField) { + return + } + if (dataField.value != null) { + (dataField.value as Set).removeAll(choicesToRemove) + } + + if (dataField.choices != null) { + dataField.choices.removeAll(choicesToRemove) + } + } + } + + /** + * Changes value from FileFieldValue to FileListFieldValue + * @param useCase Instance of Case + * @param fieldId Field ID for value change + */ + static void changeFileFieldToFileList(Case useCase, String fieldId) { + log.debug("Starting changeFileFieldToFileList for case: ${useCase.stringId}, field: ${fieldId}") + FileListFieldValue fileListFieldValue = new FileListFieldValue() + DataField dataField = useCase.dataSet[fieldId] + if (!dataField) { + return + } + def existingValue = dataField.value + if (existingValue != null) { + fileListFieldValue.namesPaths.add(existingValue as FileFieldValue) + } + dataField.value = fileListFieldValue + log.trace("Converted field ${fieldId} from FileFieldValue to FileListFieldValue in case: ${useCase.stringId}") + } + + /** + * Update dataField and dataRef components of given case + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + static void updateCaseComponents(Case useCase, PetriNet net) { + log.debug("Starting updateCaseComponents for case: ${useCase.stringId}, net: ${net.stringId}") + Map components = PetriNetMigrationHelper.createComponentsMap(net) + Map> dataRefComponents = PetriNetMigrationHelper.createDataRefComponentsMap(net) + + useCase.dataSet.each { dataField -> + if (components[dataField.key]) { + log.trace("Updating component for field ${dataField.key} in case: ${useCase.stringId}") + useCase.dataSet[dataField.key].component = components[dataField.key] + } + if (dataRefComponents[dataField.key]) { + log.trace("Updating dataRef components for field ${dataField.key} in case: ${useCase.stringId}") + useCase.dataSet[dataField.key].dataRefComponents = dataRefComponents[dataField.key] + } + } + } + + /** + * Updates case permissions from PetriNet + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void updateCasePermissionsFromNet(Case useCase, PetriNet net, boolean updateTasks = false + , MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateCasePermissionsFromNet for case: ${useCase.stringId}, net: ${net.stringId}, updateTasks: ${updateTasks}") + useCase.permissions = net.getPermissions().entrySet().stream() + .filter(role -> role.getValue().containsKey("delete") || role.getValue().containsKey("view")) + .map(role -> { + Map permissionMap = new HashMap<>() + if (role.getValue().containsKey("delete")) + permissionMap.put("delete", role.getValue().get("delete")) + if (role.getValue().containsKey("view")) { + permissionMap.put("view", role.getValue().get("view")) + } + return new AbstractMap.SimpleEntry<>(role.getKey(), permissionMap) + }) + .collect(Collectors.toMap(AbstractMap.SimpleEntry::getKey, AbstractMap.SimpleEntry::getValue)) + useCase.resolveViewRoles() + useCase.setEnabledRoles(net.getRoles().keySet()) + log.trace("Updated permissions and enabled roles for case: ${useCase.stringId}") + if (updateTasks) { + useCase.tasks.each { taskPair -> + taskMigrationHelper.updateTaskPermissions(useCase, taskPair, net, errorPolicy) + } + } + } + + /** + * Removes a case from both MongoDB and Elasticsearch. + * Deletes the case document from MongoDB and removes its corresponding index entry from Elasticsearch. + * If the MongoDB deletion is not acknowledged, an error is logged and handled according to the error policy. + * + * @param useCase The case instance to be removed from the system. + * @param errorPolicy The error handling policy to apply if the removal fails. Defaults to the default error policy. + */ + void removeCase(Case useCase, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting removeCase for case: ${useCase.stringId}") + DeleteResult deleteResult = mongoTemplate.remove(useCase) + log.trace("MongoDB delete result for case ${useCase.stringId}: acknowledged=${deleteResult.wasAcknowledged()}, deletedCount=${deleteResult.deletedCount}") + if (!deleteResult.wasAcknowledged()) { + String message = "Failed to delete case ${useCase.stringId} from MongoDB" + log.error(message) + handleMigrationError(errorPolicy, "removeCase", type, useCase.stringId, message) + return + } + elasticCaseService.remove(useCase.getStringId()) + log.trace("Successfully removed case ${useCase.stringId} from Elasticsearch") + } + + /** + * Changes PetriNet reference in useCase + * @param useCase Instance of Case + * @param newNet Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void migratePetriNet(Case useCase, PetriNet newNet, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting migratePetriNet for case: ${useCase.stringId}, new net: ${newNet.stringId}") + ProcessResourceId newCaseId = new ProcessResourceId(newNet.getStringId(), useCase.get_id().getObjectId()) + useCase.set_id(newCaseId) + useCase.setPetriNetObjectId(newNet.objectId) + log.trace("Updated petriNet reference for case: ${useCase.stringId} to net: ${newNet.stringId}") + } +} + diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/PetriNetMigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/PetriNetMigrationHelper.groovy new file mode 100644 index 00000000000..16ba90190b8 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/PetriNetMigrationHelper.groovy @@ -0,0 +1,536 @@ +package com.netgrif.application.engine.migration.helpers + +import com.netgrif.application.engine.auth.service.UserService +import com.netgrif.application.engine.configuration.properties.MigrationProperties +import com.netgrif.application.engine.importer.service.Importer +import com.netgrif.application.engine.objects.petrinet.domain.I18nString +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet +import com.netgrif.application.engine.objects.petrinet.domain.Transition +import com.netgrif.application.engine.objects.petrinet.domain.dataset.ActorField +import com.netgrif.application.engine.objects.petrinet.domain.dataset.Field +import com.netgrif.application.engine.objects.petrinet.domain.dataset.FieldType +import com.netgrif.application.engine.objects.petrinet.domain.events.Event +import com.netgrif.application.engine.objects.petrinet.domain.events.EventType +import com.netgrif.application.engine.objects.petrinet.domain.roles.ProcessRole +import com.netgrif.application.engine.objects.workflow.domain.Case +import com.netgrif.application.engine.petrinet.domain.roles.ProcessRoleRepository +import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService +import groovy.util.logging.Slf4j +import org.apache.tomcat.util.http.fileupload.IOUtils +import org.springframework.beans.factory.ObjectFactory +import org.springframework.core.io.ClassPathResource +import org.springframework.core.io.Resource +import org.springframework.data.domain.Pageable +import org.springframework.data.mongodb.core.BulkOperations +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.stereotype.Component + +import java.text.Collator +import java.util.stream.Collectors +/** + * Helper class for managing Petri Net migration operations in MongoDB. + *

+ * This component provides utilities for migrating and updating Petri Net models, including: + *

    + *
  • Updating existing Petri Nets while preserving role references
  • + *
  • Managing process roles and permissions
  • + *
  • Handling data set migrations
  • + *
  • Creating and updating roles in Petri Net models
  • + *
  • Bulk operations for efficient database updates
  • + *
+ *

+ * The helper extends {@link AbstractMigrationHelper} to leverage common migration patterns + * and uses Spring Data MongoDB for database operations. + * + * @see AbstractMigrationHelper* @see PetriNet* @see IPetriNetService* @see ProcessRoleRepository + */ +@Slf4j +@Component +class PetriNetMigrationHelper extends AbstractMigrationHelper { + + /** + * Service interface for managing Petri Net operations including importing, saving, and retrieving Petri Net models. + */ + protected final IPetriNetService petriNetService + + /** + * Repository for persisting and retrieving process roles from the database. + */ + protected final ProcessRoleRepository processRoleRepository + + /** + * Provider that supplies {@link Importer} instances for importing Petri Net models from various sources. + * Uses lazy initialization to create Importer instances on demand. + */ + protected final ObjectFactory importerProvider + + /** + * Service for managing user-related operations, including retrieving system user for Petri Net imports. + */ + protected final UserService userService + + /** + * Constructs a new PetriNetMigrationHelper with the specified dependencies. + * + * @param mongoTemplate the {@link MongoTemplate} to use for interacting with MongoDB + * @param migrationProperties the {@link MigrationProperties} containing migration settings including page size and other configuration + * @param petriNetService the {@link IPetriNetService} for managing Petri Net operations such as importing, saving, and retrieving Petri Nets + * @param processRoleRepository the {@link ProcessRoleRepository} for persisting and retrieving process roles from the database + * @param importerProvider the {@link ObjectFactory} that supplies {@link Importer} instances for importing Petri Net models from various sources + * @param userService the {@link UserService} for managing user-related operations, including retrieving system user for Petri Net imports + */ + PetriNetMigrationHelper(MongoTemplate mongoTemplate, + MigrationProperties migrationProperties, + IPetriNetService petriNetService, + ProcessRoleRepository processRoleRepository, + ObjectFactory importerProvider, + UserService userService) { + super(PetriNet.class, mongoTemplate, migrationProperties) + this.petriNetService = petriNetService + this.processRoleRepository = processRoleRepository + this.importerProvider = importerProvider + this.userService = userService + } + + /** + * Returns the page size for pagination during migration operations. + * + * @return the page size configured in {@link MigrationProperties.PetriNetMigrationProperties} + */ + @Override + int getPageSize() { + return migrationProperties.petriNets.pageSize + } + + /** + * Prepares bulk operations for updating a Petri Net document in MongoDB. + * + * @param document the {@link PetriNet} document to be updated + * @param update the closure that performs the update operation on the document + * @param bulkOperations the {@link BulkOperations} to add the replace operation to + */ + @Override + void prepareOperations(PetriNet document, Closure update, BulkOperations bulkOperations) { + log.debug("Updating Petri Net with ID ${document.stringId}") + update(document) + bulkOperations.replaceOne(Query.query(Criteria.where("_id").is(document.getObjectId())), document) + } + + /** + * Resolves and returns the string identifier of a Petri Net document. + *

+ * This method is used during migration operations to uniquely identify Petri Net documents + * when performing bulk operations or logging migration progress. + * + * @param document the {@link PetriNet} document whose identifier should be resolved + * @return the string representation of the Petri Net's unique identifier + */ + @Override + String resolveId(PetriNet document) { + return document.getStringId() + } + + /** + * Updates existing Petri Net model with new values. New process roles are ignored! New roles in existing user type fields will be ignored! + * @param identifier Identifier of Petri Net model that is being updated + * @param resource Resource object with new version of Petri Net model + */ + void updateNetIgnoreRoles(String identifier, Resource resource, List> customUpdates = null) { + log.debug("Starting updateNetIgnoreRoles for identifier: {} with Resource", identifier) + PetriNet reimported = petriNetService.importPetriNet(resource.inputStream, VersionType.MAJOR, userService.getSystem().transformToLoggedUser()).getNet() + updateNetIgnoreRoles(petriNetService.getDefaultVersionByIdentifier(identifier), reimported, customUpdates) + } + + /** + * Updates existing Petri Net model with new values. New process roles are ignored! New roles in existing user type fields will be ignored! + * @param identifier Identifier of Petri Net model that is being updated + * @param fileName File name of new version of Petri Net model + */ + void updateNetIgnoreRoles(String identifier, String fileName, List> customUpdates = null) { + log.debug("Starting updateNetIgnoreRoles for identifier: {} with fileName: {}", identifier, fileName) + PetriNet currentNet = petriNetService.getDefaultVersionByIdentifier(identifier) + InputStream inputStream = new ClassPathResource("petriNets/$fileName" as String).inputStream + ByteArrayOutputStream outputStream = new ByteArrayOutputStream() + IOUtils.copy(inputStream, outputStream) + PetriNet reimported = getImporter().importPetriNet(new ByteArrayInputStream(outputStream.toByteArray())) + .orElseThrow { new IllegalStateException("Failed to import Petri Net from file: $fileName") } + updateNetIgnoreRoles(currentNet, reimported, customUpdates) + } + + /** + * Updates existing Petri Net model with new values. New process roles are ignored! New roles in existing user type fields will be ignored! + * @param currentNet Current Petri Net object that will be updated + * @param reimported New version of Petri Net object, its values will be applied to currentNet + * @param customUpdates Optional list of custom update closures to be applied after the standard update + */ + void updateNetIgnoreRoles(PetriNet currentNet, PetriNet reimported, List> customUpdates) { + log.debug("Starting updateNetIgnoreRoles for currentNet: {} and reimported: {}", currentNet?.identifier, reimported?.identifier) + if (!currentNet) { + log.warn("Net $reimported.identifier does not exist") + return + } + Map oldProcessRoles = currentNet.roles + Map newProcessRoles = reimported.roles + + reimported = replaceUserFieldRoleReferences(currentNet, reimported) + + ProcessRole defaultRole = processRoleRepository.findAllByName_DefaultValue(ProcessRole.DEFAULT_ROLE, Pageable.ofSize(1)).first() + ProcessRole anonymousRole = processRoleRepository.findAllByName_DefaultValue(ProcessRole.ANONYMOUS_ROLE, Pageable.ofSize(1)).first() + + currentNet.places = reimported.places + currentNet.transitions = reimported.transitions + currentNet.arcs = reimported.arcs + currentNet.dataSet = reimported.dataSet + currentNet.transactions = reimported.transactions + currentNet.importId = reimported.importId + currentNet.caseEvents = reimported.caseEvents + currentNet.processEvents = reimported.processEvents + currentNet.negativeViewRoles = reimported.negativeViewRoles + currentNet.actorRefs = reimported.actorRefs + currentNet.functions = reimported.functions + + def newPermissions = [:] + reimported.permissions.each { id, permissions -> + log.trace("Processing permission for role id: {}", id) + def newRole = newProcessRoles[id] + + if (!newRole && (defaultRole.stringId == id || anonymousRole.stringId == id)) { + log.info("Default role $id on process $currentNet.identifier detected, skipping") + newPermissions[id] = permissions + + } else { + def oldRole = oldProcessRoles.values().find { + it.importId == newRole.importId + } + + if (!oldRole) { + log.warn("Old role does not exist for role $newRole.importId") + return + } + log.trace("Mapping new role {} to old role {}", newRole.importId, oldRole.stringId) + newPermissions[oldRole.stringId] = permissions + } + } + currentNet.permissions = newPermissions as Map> + + currentNet.transitions.each { id, t -> + log.trace("Processing transition roles for transition: {}", t.importId) + Map> oldRoles = new HashMap<>() + t.roles.each { roleMongoId, permissions -> + log.trace("Processing transition role with mongoId: {}", roleMongoId) + def newRole = newProcessRoles[roleMongoId] + + if (!newRole && (defaultRole.stringId == roleMongoId || anonymousRole.stringId == roleMongoId)) { + log.info("Default role $roleMongoId on transition ${t.importId} detected, skipping") + oldRoles[roleMongoId] = permissions + + } else { + def oldRole = oldProcessRoles.values().find { + it.importId == newRole.importId + } + + if (!oldRole) { + log.warn("Old role does not exist for role $newRole.importId") + return + } + log.trace("Mapping transition role {} to old role {}", newRole.importId, oldRole.stringId) + oldRoles[oldRole.stringId] = permissions + } + } + t.roles = oldRoles + } + + resolveDataOrder(currentNet) + + customUpdates && customUpdates.each { Closure customUpdate -> + currentNet = customUpdate(currentNet, reimported) + } + + petriNetService.save(currentNet) + log.info("Migrated $currentNet.identifier") + } + + /** + * Helper method used in updateNetIgnoreRoles method, it sorts PetriNet dataSet alphabetically + * @param petriNet Instance of Petri Net + */ + static void resolveDataOrder(PetriNet petriNet, Locale locale = Locale.ROOT) { + Collator collator = Collator.getInstance(locale) + List fields = new LinkedList<>(petriNet.getDataSet().values()) + fields = fields.stream().sorted({ f1, f2 -> + int comparedTypes = f2.type.name <=> f1.type.name + if (comparedTypes != 0) return comparedTypes + return collator.compare((f1.name?.defaultValue ?: f1.stringId), (f2.name?.defaultValue ?: f2.stringId)) + }).collect(Collectors.toList()) + petriNet.dataSet = fields.collectEntries { [(it.getStringId()): (it)] } as LinkedHashMap + } + + /** + * Replaces role permissions on transition with provided map e.g. ["roleId": ["perform": true]] + * @param net Instance of Petri Net in which role on transition will be updated + * @param transitionId Transition ID of updated transition + * @param role ProcessRole that will be updated on transition + * @param permissions New role permissions on transition + */ + static void updateTransitionRoles(PetriNet net, String transitionId, ProcessRole role, Map permissions) { + log.debug("Updating transition roles for transitionId: {} in net: {}", transitionId, net?.identifier) + Transition trans = net.transitions.values().find { it.importId == transitionId } + if (!trans) { + log.warn("Transition with importId $transitionId not found in net $net.identifier") + return + } + trans.roles[role.stringId] = permissions + } + + /** + * Replaces role permissions on transition with provided map e.g. ["roleId": ["perform": true]] + * @param net Instance of Petri Net in which role on transition will be updated + * @param transitionId Transition ID of updated transition + * @param roleImportId ID of a role that will be updated on transition + * @param permissions New role permissions on transition + */ + static void updateTransitionRoles(PetriNet net, String transitionId, String roleImportId, Map permissions) { + ProcessRole role = net.roles.values().find { it.importId == roleImportId } + if (!role) { + log.warn("Transition with importId $transitionId not found in net $net.identifier") + return + } + updateTransitionRoles(net, transitionId, role, permissions) + } + + /** + * Replaces role permissions on transition with provided map e.g. ["roleId": ["perform": true]] + * @param transitionId Transition ID of updated transition + * @param roleImportId ID of a role that will be updated on transition + * @param permissions New role permissions on transition + */ + static Closure updateTransitionRolesClosure(String transitionId, String roleImportId, Map permissions) { + return { PetriNet petriNet, PetriNet reimported -> + updateTransitionRoles(petriNet, transitionId, roleImportId, permissions) + return petriNet + } + } + + /** + * Updates data set of existing Petri Net model with new values. + * @param identifier Identifier of Petri Net model that is being updated + * @param fileName File name of new version of Petri Net model + */ + void updateDataSet(String identifier, String fileName, Closure customUpdate = null) { + log.debug("Starting updateDataSet for identifier: {} with fileName: {}", identifier, fileName) + PetriNet existing = petriNetService.getDefaultVersionByIdentifier(identifier) + InputStream inputStream = new ClassPathResource("petriNets/$fileName" as String).inputStream + PetriNet reimported = getImporter().importPetriNet(inputStream) + .orElseThrow { new IllegalStateException("Failed to import Petri Net from file: $fileName") } + + reimported = replaceUserFieldRoleReferences(existing, reimported) + + existing.dataSet = reimported.dataSet + + if (customUpdate) { + existing = customUpdate(existing, reimported) + } + + petriNetService.save(existing) + log.info("Migrated $identifier") + } + + /** + * Create new role in existing Petri Net model. + * @param identifier Identifier of Petri Net model in which the Process Role will be created + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + ProcessRole createRoleInNet(String identifier, String id, String title, Map events = [:]) { + log.debug("Creating role in net with identifier: {}, id: {}, title: {}", identifier, id, title) + return createRoleInNet(identifier, id, new I18nString(title), events) + } + + /** + * Create new role in existing Petri Net model. + * @param identifier Identifier of Petri Net model in which the Process Role will be created + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + ProcessRole createRoleInNet(String identifier, String id, I18nString title, Map events = [:]) { + log.debug("Creating role in net with identifier: {}, id: {}, title: {}", identifier, id, title?.defaultValue) + PetriNet net = petriNetService.getDefaultVersionByIdentifier(identifier) + + ProcessRole role = new com.netgrif.application.engine.adapter.spring.petrinet.domain.roles.ProcessRole() + role.setImportId(id) + role.setName(title) + role.setEvents(events) + + role = processRoleRepository.save(role) + net.addRole(role) + petriNetService.save(net) + + return role + } + + /** + * Updates roles of USER fields in existing Petri Net model, WARNING: new roles referenced in USER fields will be ignored! They need to be migrated manually + * @param originalNet Current Petri Net object that will be updated + * @param reimportedNet New version of Petri Net object, its values will be applied to currentNet + * @return the updated reimported Petri Net with replaced role references + */ + private static PetriNet replaceUserFieldRoleReferences(PetriNet originalNet, PetriNet reimportedNet) { + Map originalNetRoles = [:] // importId: processRole + originalNet.roles.forEach { name, role -> + originalNetRoles.put(role.importId, role) + } + + reimportedNet.dataSet.entrySet().stream().filter { + it.value.type == FieldType.ACTOR + + }.forEach { entry -> + log.trace("Processing user field role references for field: {}", entry.key) + ActorField field = (reimportedNet.dataSet[entry.key] as ActorField) + field.roles = field.roles.collect { roleId -> + Optional roleOpt = Optional.ofNullable(reimportedNet.roles[roleId]) + if (roleOpt.isPresent()) { + ProcessRole oldRole = originalNetRoles[roleOpt.get().importId] + + if (!oldRole) { + log.warn("Process role in process ${originalNet.identifier} ${originalNet.stringId} with import id ${roleOpt.get().importId} not found!") + return null + + } else { + log.trace("Mapped user field role {} to {}", roleOpt.get().importId, oldRole.stringId) + return oldRole.stringId + } + + } else { + log.warn("Role not found! ${roleId}") + return null + } + }.stream().filter { Objects.nonNull(it) }.collect() + + } + + return reimportedNet + } + + /** + * Creates new global role + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + ProcessRole createGlobalRole(String id, String title, Map events = [:]) { + log.debug("Creating global role with id: {}, title: {}", id, title) + return createGlobalRole(id, new I18nString(title), events) + } + + /** + * Creates new global role + * @param id ID of the new Process Role + * @param title Title of the new Process Role + */ + ProcessRole createGlobalRole(String id, I18nString title, Map events = [:]) { + log.debug("Creating global role with id: {}, title: {}", id, title?.defaultValue) + ProcessRole role = new com.netgrif.application.engine.adapter.spring.petrinet.domain.roles.ProcessRole() + + if (!id.startsWith("global_")) { + role.setImportId("global_" + id) + } else { + role.setImportId(id) + } + role.setName(title) + role.setEvents(events) + role.setGlobal(true) + + role = processRoleRepository.save(role) + + return role + } + + /** + * Replaces events in roles from existing with events from roles from reimported + * @param existing the existing {@link PetriNet} whose role events will be updated + * @param reimported the reimported {@link PetriNet} containing new role events + * @return the updated existing Petri Net + */ + PetriNet updateRoleEvents(PetriNet existing, PetriNet reimported) { + log.debug("Starting updateRoleEvents for existing net: {} and reimported net: {}", existing?.identifier, reimported?.identifier) + List newRoles = reimported.roles.values() as List + List oldRoles = existing.roles.values() as List + + newRoles.each { newRole -> + log.trace("Processing role events for role: {}", newRole.importId) + ProcessRole role = oldRoles.find { it.importId == newRole.importId } + if (!role) { + log.warn("No existing role found for importId $newRole.importId, skipping event update") + return + } + role.events = newRole.events + processRoleRepository.save(role) + } + + return existing + } + + /** + * Sets petriNet object in case instance + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + static void setPetriNet(Case useCase, PetriNet net) { + log.debug("Setting PetriNet for case: {} with net: {}", useCase?.stringId, net?.identifier) + PetriNet model = new com.netgrif.application.engine.adapter.spring.petrinet.domain.PetriNet(net as com.netgrif.application.engine.adapter.spring.petrinet.domain.PetriNet) + model.initializeTokens(useCase.getActivePlaces()) + model.initializeArcs(useCase.getDataSet()) + useCase.setPetriNet(model) + } + + /** + * Provides an {@link com.netgrif.application.engine.importer.service.Importer} instance + * @return a new {@link Importer} instance from the provider + * */ + Importer getImporter() { + return importerProvider.getObject() + } + + /** + * Method that collects all dataRef components of given PetriNet. Should be used in updateCases method, when a new dataRef component is added into PetriNet. + * @param net Instance of PetriNet + */ + static Map> createDataRefComponentsMap(PetriNet net) { + log.debug("Creating dataRef components map for net: {}", net?.identifier) + Map> componentsMap = [:] + net.transitions.each { transition -> + String transId = transition.key + transition.value.dataSet.each { dataField -> + String fieldId = dataField.key + if (dataField.value.component) { + log.trace("Adding dataRef component for field: {} in transition: {}", fieldId, transId) + if (!componentsMap[fieldId]) { + componentsMap.put(fieldId, [(transId) : dataField.value.component]) + } else { + Map existingMap = componentsMap[fieldId] + existingMap.put(transId, dataField.value.component) + componentsMap.put(fieldId, existingMap) + } + } + } + } + return componentsMap + } + + /** + * Method that collects all dataField components of given PetriNet. Should be used in updateCases method, when a new dataField component is added into PetriNet. + * @param net Instance of PetriNet + */ + static Map createComponentsMap(PetriNet net) { + log.debug("Creating components map for net: {}", net?.identifier) + Map componentsMap = [:] + net.dataSet.each { dataField -> + if (dataField.value.component) { + log.trace("Adding component for field: {}", dataField.key) + componentsMap.put(dataField.key, dataField.value.component) + } + } + return componentsMap + } +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/TaskMigrationHelper.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/TaskMigrationHelper.groovy new file mode 100644 index 00000000000..dbb369f4249 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/helpers/TaskMigrationHelper.groovy @@ -0,0 +1,281 @@ +package com.netgrif.application.engine.migration.helpers + +import com.netgrif.application.engine.adapter.spring.workflow.domain.QTask +import com.netgrif.application.engine.configuration.properties.MigrationProperties +import com.netgrif.application.engine.elastic.service.interfaces.IElasticTaskMappingService +import com.netgrif.application.engine.elastic.service.interfaces.IElasticTaskService +import com.netgrif.application.engine.migration.model.MigrationErrorPolicy +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet +import com.netgrif.application.engine.objects.petrinet.domain.Transition +import com.netgrif.application.engine.objects.petrinet.domain.roles.ProcessRole +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.TaskPair +import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService +import com.netgrif.application.engine.workflow.service.interfaces.ITaskService +import com.querydsl.core.types.Predicate +import groovy.util.logging.Slf4j +import org.springframework.data.mongodb.core.BulkOperations +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.stereotype.Component + +/** + * A helper class for managing task migrations. + * This class extends {@link AbstractMigrationHelper} and provides methods for updating, iterating, + * and manipulating {@link Task} entities in bulk during migration processes. + * It integrates with MongoDB and uses the {@link MongoTemplate} for data operations and + * {@link IPetriNetService} for interacting with PetriNet services. + */ +@Slf4j +@Component +class TaskMigrationHelper extends AbstractMigrationHelper { + + /** + * Service for handling Petri Net operations. + * + * This service is used to access and interact with Petri Net tasks, + * such as retrieving the latest version of a Petri Net by its identifier + * during task migrations. + */ + protected final IPetriNetService petriNetService + + /** + * Service for handling task operations. + * + * This service provides methods for managing task entities, + * including finding, saving, and reloading tasks during migration processes. + */ + protected final ITaskService taskService + + /** + * Service for handling Elasticsearch task indexing operations. + * + * This service is used to index task documents into Elasticsearch, + * enabling full-text search and analytics capabilities for tasks. + */ + protected final IElasticTaskService elasticTaskService + + /** + * Service for mapping task entities to Elasticsearch documents. + * + * This service transforms task domain objects into their Elasticsearch + * representation before indexing, ensuring proper field mapping and data structure. + */ + protected final IElasticTaskMappingService elasticTaskMappingService + + /** + * Constructs a new TaskMigrationHelper with the specified MongoTemplate. + * + * @param mongoTemplate the {@link MongoTemplate} to use for interacting with MongoDB + */ + TaskMigrationHelper(MongoTemplate mongoTemplate, + MigrationProperties migrationProperties, + IPetriNetService petriNetService, + ITaskService taskService, + IElasticTaskService elasticTaskService, + IElasticTaskMappingService elasticTaskMappingService) { + super(Task.class, mongoTemplate, migrationProperties) + this.petriNetService = petriNetService + this.taskService = taskService + this.elasticTaskService = elasticTaskService + this.elasticTaskMappingService = elasticTaskMappingService + } + + /** + * Returns the page size for the task migration process. + * + * The page size is configured in the {@link MigrationProperties.TaskMigrationProperties} and determines + * the number of tasks processed in a single batch during migration operations. + * + * @return an integer indicating the configured page size + */ + @Override + int getPageSize() { + return migrationProperties.tasks.pageSize + } + + /** + * Prepares a set of bulk operations for tasks during the migration process. + * + * This method is called for each individual {@link Task} document that needs to be updated. + * It executes the provided {@code update} closure to modify the task and + * prepares a bulk replacement operation to save the changes to the database. + * + * @param document the {@link Task} document to be updated + * @param update a {@link Closure} that defines the update logic to be applied to the {@link Task} + * @param bulkOperations the {@link BulkOperations} object used to queue the MongoDB operations for batch execution + */ + @Override + void prepareOperations(Task document, Closure update, BulkOperations bulkOperations) { + log.debug("Updating task with ID ${document.stringId}") + update(document) + bulkOperations.replaceOne(Query.query(Criteria.where("_id").is(document.getObjectId())), document) + } + + /** + * Resolves and returns the unique identifier of the given task document. + * + * This method extracts the string representation of the task's identifier, + * which is used for logging and tracking purposes during migration operations. + * + * @param document the {@link Task} document whose identifier should be resolved + * @return a {@link String} representing the unique identifier of the task + */ + @Override + String resolveId(Task document) { + return document.getStringId() + } + + /** + * Updates all tasks filtered by filter Predicate. Update closure is called on each filtered task. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter + * @param filter Instance of Predicate, to filter which tasks should be updated + */ + void updateTasks(Closure update, Predicate filter, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateTasks with filter: ${filter.toString()}") + log.info("Updating tasks with filter ${filter.toString()} and update ${update.toString()}") + log.trace("Converting filter to query and calling iterate") + iterate(update, null, toQuery(filter), 0, getPageSize(), errorPolicy) + } + + /** + * Iterates all tasks filtered by filter Predicate. Update closure is called on each filtered task. PageProcessed closure is called after each page iteration. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter (changes made to Task will not be saved automatically, for that use updateCases method) + * @param sleepFor Optional attribute to set sleep time (in milliseconds) to sleep for after each iterated page. Default 0ms + * @param filter Instance of Predicate, to filter which tasks should be iterated + */ + void iterateTasks(Closure update, Closure pageProcessed = null, long sleepFor = 0, Predicate filter, + MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting iterateTasks with filter: ${filter.toString()}, sleepFor: ${sleepFor}ms") + log.trace("Converting filter to query and calling iterate with pageProcessed closure") + iterate(update, pageProcessed, toQuery(filter), sleepFor, getPageSize(), errorPolicy) + } + + /** + * Updates all tasks of a given process. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter + * @param processIdentifier identifier of PetriNet, to filter which tasks should be updated + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateTasksCursor(Closure update, String processIdentifier, int pageSize = 100, + MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateTasksCursor for processIdentifier: ${processIdentifier}, pageSize: ${pageSize}") + String processId = petriNetService.getDefaultVersionByIdentifier(processIdentifier).stringId + Query query = new Query(Criteria.where("processId").is(processId)) + log.trace("Created query for processId: ${processId}, calling iterate") + iterate(update, null, query, 0, pageSize as int, errorPolicy) + } + + /** + * Updates specific tasks of a given process. + * @param update Instance of Closure, which should contain code that will be executed for every Task matched by filter + * @param processIdentifier identifier of PetriNet, to filter which tasks should be updated + * @param transitionIds List of transition IDs to limit filter to specific transitions of given processIdentifier + * @param pageSize Optional attribute to set page size. Default page size 100 + */ + void updateSpecificTasksCursor(Closure update, String processIdentifier, List transitionIds, int pageSize = 100, + MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateSpecificTasksCursor for processIdentifier: ${processIdentifier}, transitionIds: ${transitionIds}, pageSize: ${pageSize}") + String processId = petriNetService.getDefaultVersionByIdentifier(processIdentifier).stringId + Query query = new Query(Criteria.where("processId").is(processId)) + query.addCriteria(Criteria.where("transitionId").in(transitionIds)) + log.trace("Created query with criteria for processId: ${processId} and transitionIds: ${transitionIds}, calling iterate") + iterate(update, null, query, 0, pageSize as int, errorPolicy) + } + + /** + * Update all tasks. + * @param update Instance of Closure, which should contain code that will be executed for every Task + * @param pageSize Optional attribute to set page size. Default page size 100.0 + */ + void updateAllTasksCursor(Closure update, int pageSize = 100, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateAllTasksCursor with pageSize: ${pageSize}") + log.trace("Calling iterate with empty query to process all tasks") + iterate(update, null, new Query(), 0, pageSize as int, errorPolicy) + } + + /** + * Reloads tasks of provided case via TaskService, + * handles useCase.petriNet internally + * @param useCase Instance of Case for which tasks will be reloaded + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void reloadTasks(Case useCase, PetriNet net) { + log.debug("Starting reloadTasks for case: ${useCase.stringId}, net identifier: ${net.identifier}") + PetriNetMigrationHelper.setPetriNet(useCase, net) + log.trace("Set PetriNet for case, calling taskService.reloadTasks") + taskService.reloadTasks(useCase, false) + } + + /** + * Indexes provided task in elasticsearch + * @param task Instance of Task that will be indexed into elasticsearch index + */ + void elasticTaskIndex(Task task, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting elasticTaskIndex for task: ${task.stringId}") + try { + log.trace("Transforming and indexing task: ${task.stringId} into elasticsearch") + elasticTaskService.indexNow(elasticTaskMappingService.transform(task)) + } catch (Exception e) { + String message = "Failed to index $task.stringId" + log.error(message, e) + handleMigrationError(errorPolicy, "elasticTaskIndex", type, task.stringId, message, e) + } + } + + /** + * Adds role with permissions to existing tasks of net + * @param role ProcessRole that will be added to transitions + * @param net Instance of Petri Net of updated transitions + * @param transitionIds List of transition IDs the role will be added to + * @param permissions Map of permissions for the role + */ + void addRoleToExistingTasks(ProcessRole role, PetriNet net, List transitionIds, Map permissions) { + log.debug("Starting addRoleToExistingTasks for role: ${role.getName()}, net: ${net.identifier}, transitionIds: ${transitionIds}") + log.trace("Calling updateTasks to add role with permissions: ${permissions}") + updateTasks({ Task task -> + log.trace("Add role '${role.getName()}' with roleId=${role.getImportId()} to transitionId=${task.getTransitionId()} in task ${task.stringId}") + task.addRole(role.getStringId(), permissions) + }, QTask.task.transitionId.in(transitionIds) & QTask.task.processId.eq(net.getStringId())) + } + + /** + * Updates permissions on existing tasks filtered by relevantTransitionIds + * @param useCase Instance of Case + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + * @param relevantTransitionIds List of transition IDs for permissions update + */ + void updateTasksPermissions(Case useCase, PetriNet net, List relevantTransitionIds, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateTasksPermissions for case: ${useCase.stringId}, net: ${net.identifier}, relevantTransitionIds: ${relevantTransitionIds}") + useCase.tasks.findAll { it.transition in relevantTransitionIds }.each { taskPair -> + log.trace("Processing task permissions for transition: ${taskPair.transition} in case: ${useCase.stringId}") + updateTaskPermissions(useCase, taskPair, net, errorPolicy) + } + } + + /** + * Updates permissions on existing task + * @param useCase Instance of Case + * @param taskPair TaskPair object of updated Task + * @param net Instance of Petri Net, it needs to match processIdentifier of useCase + */ + void updateTaskPermissions(Case useCase, TaskPair taskPair, PetriNet net, MigrationErrorPolicy errorPolicy = defaultErrorPolicy()) { + log.debug("Starting updateTaskPermissions for case: ${useCase.stringId}, task transition: ${taskPair.transition}") + try { + Transition newTransition = net.getTransition(taskPair.transition) + Task oldTask = taskService.findOne(taskPair.task) + log.trace("Updating task roles and permissions for task: ${oldTask.stringId}") + oldTask.setProcessId(net.stringId) + oldTask.setRoles(newTransition.roles) + oldTask.setNegativeViewRoles(newTransition.negativeViewRoles) + oldTask.resolveViewRoles() + taskService.save(oldTask) + } catch (Exception e) { + String message = "Failed to update task permissions $useCase.stringId $taskPair.transition" + log.error(message, e) + handleMigrationError(errorPolicy, "updateTaskPermissions", type, taskPair?.task?.toString(), message, e) + } + } +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationError.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationError.groovy new file mode 100644 index 00000000000..493802ae379 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationError.groovy @@ -0,0 +1,162 @@ +package com.netgrif.application.engine.migration.model + +import java.time.LocalDateTime + +/** + * Represents an error that occurred during a migration operation. + *

+ * This class captures detailed information about migration failures including timestamp, + * the helper class involved, the operation being performed, the entity type and ID, + * error message, and the underlying cause of the error. + *

+ */ +class MigrationError { + + + /** + * The timestamp when the error occurred. + */ + private LocalDateTime timestamp + + /** + * The name of the helper class where the error occurred. + */ + private String helper + + /** + * The operation being performed when the error occurred. + */ + private String operation + + /** + * The type of entity involved in the migration. + */ + private Class entityType + + /** + * The ID of the entity involved in the migration. + */ + private String entityId + + /** + * A descriptive error message. + */ + private String message + + /** + * The underlying exception that caused the error, or null if none. + */ + private Throwable cause + + /** + * Constructs a new MigrationError with the specified details. + * + * @param timestamp the timestamp when the error occurred + * @param helper the name of the helper class where the error occurred + * @param operation the operation being performed when the error occurred + * @param entityType the type of entity involved in the migration + * @param entityId the ID of the entity involved in the migration + * @param message a descriptive error message + * @param cause the underlying exception that caused the error, or null if none + */ + MigrationError(LocalDateTime timestamp, String helper, String operation, Class entityType, String entityId, String message, Throwable cause) { + this.timestamp = timestamp + this.helper = helper + this.operation = operation + this.entityType = entityType + this.entityId = entityId + this.message = message + this.cause = cause + } + + /** + * Factory method to create a new MigrationError with the current timestamp. + * + * @param helper the name of the helper class where the error occurred + * @param operation the operation being performed when the error occurred + * @param entityType the type of entity involved in the migration + * @param entityId the ID of the entity involved in the migration + * @param message a descriptive error message + * @param cause the underlying exception that caused the error (optional, defaults to null) + * @return a new MigrationError instance with the current timestamp + */ + static MigrationError of(String helper, + String operation, + Class entityType, + String entityId, + String message, + Throwable cause = null) { + return new MigrationError( + LocalDateTime.now(), + helper, + operation, + entityType, + entityId, + message, + cause + ) + } + + /** + * Returns the timestamp when the error occurred. + * + * @return the timestamp of the error + */ + LocalDateTime getTimestamp() { + return timestamp + } + + /** + * Returns the name of the helper class where the error occurred. + * + * @return the helper class name + */ + String getHelper() { + return helper + } + + /** + * Returns the operation being performed when the error occurred. + * + * @return the operation name + */ + String getOperation() { + return operation + } + + /** + * Returns the type of entity involved in the migration. + * + * @return the entity type + */ + String getEntityType() { + return entityType + } + + /** + * Returns the ID of the entity involved in the migration. + * + * @return the entity ID + */ + String getEntityId() { + return entityId + } + + /** + * Returns the descriptive error message. + * + * @return the error message + */ + String getMessage() { + return message + } + + /** + * Returns the underlying exception that caused the error. + * + * @return the cause of the error, or null if there is no underlying cause + */ + Throwable getCause() { + return cause + } +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationErrorHandlingMode.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationErrorHandlingMode.groovy new file mode 100644 index 00000000000..25f2b228348 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationErrorHandlingMode.groovy @@ -0,0 +1,30 @@ +package com.netgrif.application.engine.migration.model + +/** + * Defines the error handling strategies for migration operations. + *

+ * This enum specifies how errors encountered during migration should be handled, + * allowing control over whether to fail fast, continue processing, or apply limits. + */ +enum MigrationErrorHandlingMode { + + /** + * Cache/log error and immediately throw. + */ + THROW_IMMEDIATELY, + + /** + * Cache/log error and continue migration. + */ + CONTINUE, + + /** + * Cache/log error and throw once maxErrors is reached. + */ + THROW_AFTER_LIMIT, + + /** + * Cache/log error and continue processing, but throw after the operation finishes if any errors occurred. + */ + THROW_AFTER_PROCESSING +} \ No newline at end of file diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationErrorPolicy.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationErrorPolicy.groovy new file mode 100644 index 00000000000..7763230ac3e --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/model/MigrationErrorPolicy.groovy @@ -0,0 +1,195 @@ +package com.netgrif.application.engine.migration.model + +import com.netgrif.application.engine.configuration.properties.MigrationProperties.ErrorPolicy + +/** + * Configuration class that defines how errors should be handled during migration processes. + *

+ * This policy allows fine-grained control over error handling behavior including: + *

    + *
  • When to throw exceptions (immediately, after a limit, after processing, or continue)
  • + *
  • Whether to cache encountered errors
  • + *
  • Maximum number of errors to tolerate before throwing
  • + *
  • Whether to rethrow original exceptions or wrap them
  • + *
+ *

+ * Factory methods are provided for common error handling scenarios. + * + * @see MigrationErrorHandlingMode + */ + +class MigrationErrorPolicy { + + /** + * The error handling mode that determines when exceptions should be thrown. + * Defaults to {@link MigrationErrorHandlingMode#CONTINUE}. + */ + private MigrationErrorHandlingMode mode = MigrationErrorHandlingMode.CONTINUE + + /** + * Maximum number of cached errors before throwing. + * Used when mode is THROW_AFTER_LIMIT. + */ + private int maxErrors = 0 + + /** + * Whether encountered errors should be stored in the migration error cache. + */ + private boolean cacheErrors = true + + /** + * Whether to rethrow the original exception where possible. + * If false, throw MigrationErrorException with cached errors. + * Defaults to false. + */ + private boolean throwOriginal = false + + /** + * Creates a default error policy based on application configuration properties. + * This factory method reads error handling settings from the provided migration properties + * and constructs a MigrationErrorPolicy with those settings. + * + * @param migrationProperties the migration configuration properties containing error policy settings + * @return a new MigrationErrorPolicy configured according to the application properties + */ + static MigrationErrorPolicy defaultErrorPolicy(ErrorPolicy props) { + if (props == null || props.mode == null || props.mode.trim().isEmpty()) { + return new MigrationErrorPolicy() + } + MigrationErrorHandlingMode parsedMode + try { + parsedMode = MigrationErrorHandlingMode.valueOf(props.mode.trim().toUpperCase(Locale.ROOT)) + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Invalid nae.migration.error-policy.mode '${props.mode}'. Supported values: ${MigrationErrorHandlingMode.values()*.name().join(', ')}", ex) + } + return new MigrationErrorPolicy( + mode: parsedMode, + maxErrors: props.maxErrors, + cacheErrors: props.cacheErrors, + throwOriginal: props.throwOriginal + ) + } + + /** + * Creates a policy that continues processing even when errors occur. + * Errors will be cached but will not stop the migration process. + * + * @return a new MigrationErrorPolicy configured to continue on error + */ + static MigrationErrorPolicy continueOnError() { + return new MigrationErrorPolicy(mode: MigrationErrorHandlingMode.CONTINUE) + } + + /** + * Creates a policy that throws an exception immediately when the first error is encountered. + * This stops the migration process as soon as any error occurs. + * + * @return a new MigrationErrorPolicy configured to throw immediately on error + */ + static MigrationErrorPolicy throwImmediately() { + return new MigrationErrorPolicy(mode: MigrationErrorHandlingMode.THROW_IMMEDIATELY) + } + + /** + * Creates a policy that throws an exception after a specified number of errors have been encountered. + * This allows the migration to tolerate a limited number of errors before failing. + * + * @param maxErrors the maximum number of errors to cache before throwing an exception + * @return a new MigrationErrorPolicy configured to throw after reaching the error limit + */ + static MigrationErrorPolicy throwAfterLimit(int maxErrors) { + if (maxErrors <= 0) { + throw new IllegalArgumentException("maxErrors must be > 0 for THROW_AFTER_LIMIT") + } + return new MigrationErrorPolicy( + mode: MigrationErrorHandlingMode.THROW_AFTER_LIMIT, + maxErrors: maxErrors + ) + } + + /** + * Creates a policy that completes the migration process and throws an exception afterward if any errors occurred. + * This allows all migration steps to be attempted before reporting failures. + * + * @return a new MigrationErrorPolicy configured to throw after processing completes + */ + static MigrationErrorPolicy throwAfterProcessing() { + return new MigrationErrorPolicy(mode: MigrationErrorHandlingMode.THROW_AFTER_PROCESSING) + } + + /** + * Gets the current error handling mode. + * + * @return the configured error handling mode + */ + MigrationErrorHandlingMode getMode() { + return mode + } + + /** + * Sets the error handling mode. + * + * @param mode the error handling mode to use + */ + void setMode(MigrationErrorHandlingMode mode) { + this.mode = mode + } + + /** + * Gets the maximum number of errors allowed before throwing an exception. + * Only relevant when mode is {@link MigrationErrorHandlingMode#THROW_AFTER_LIMIT}. + * + * @return the maximum error count threshold + */ + int getMaxErrors() { + return maxErrors + } + + /** + * Sets the maximum number of errors allowed before throwing an exception. + * + * @param maxErrors the maximum error count threshold + */ + void setMaxErrors(int maxErrors) { + if (maxErrors < 0) { + throw new IllegalArgumentException("maxErrors cannot be negative") + } + this.maxErrors = maxErrors + } + + /** + * Checks whether errors should be cached during migration. + * + * @return true if errors should be cached, false otherwise + */ + boolean getCacheErrors() { + return cacheErrors + } + + /** + * Sets whether errors should be cached during migration. + * + * @param cacheErrors true to cache errors, false otherwise + */ + void setCacheErrors(boolean cacheErrors) { + this.cacheErrors = cacheErrors + } + + /** + * Checks whether original exceptions should be rethrown. + * + * @return true if original exceptions should be rethrown, false to wrap them in MigrationErrorException + */ + boolean getThrowOriginal() { + return throwOriginal + } + + /** + * Sets whether original exceptions should be rethrown. + * + * @param throwOriginal true to rethrow original exceptions, false to wrap them in MigrationErrorException + */ + void setThrowOriginal(boolean throwOriginal) { + this.throwOriginal = throwOriginal + } +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/migration/throwable/MigrationErrorException.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/throwable/MigrationErrorException.groovy new file mode 100644 index 00000000000..b817dbedb8f --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/migration/throwable/MigrationErrorException.groovy @@ -0,0 +1,39 @@ +package com.netgrif.application.engine.migration.throwable + +import com.netgrif.application.engine.migration.model.MigrationError + +/** + * Exception thrown when one or more migration errors occur during the migration process. + *

+ * This exception extends {@link RuntimeException} and encapsulates a list of {@link MigrationError} + * objects that provide detailed information about what went wrong during migration. + * The error list is immutable once the exception is created. + *

+ */ +class MigrationErrorException extends RuntimeException { + + private final List errors + + /** + * Constructs a new MigrationErrorException with the specified detail message, list of errors, and cause. + * + * @param message the detail message describing the overall migration failure + * @param errors the list of {@link MigrationError} objects detailing individual migration errors; + * if null or empty, an empty unmodifiable list will be used + * @param cause the cause of this exception (a null value is permitted and indicates that the cause + * is nonexistent or unknown); defaults to null if not specified + */ + MigrationErrorException(String message, List errors, Throwable cause = null) { + super(message, cause) + this.errors = Collections.unmodifiableList(new ArrayList<>(errors ?: [])) + } + + /** + * Returns an unmodifiable list of migration errors that occurred. + * + * @return an unmodifiable {@link List} of {@link MigrationError} objects; never null but may be empty + */ + List getErrors() { + return errors + } +} \ No newline at end of file diff --git a/application-engine/src/main/java/com/netgrif/application/engine/configuration/properties/MigrationProperties.java b/application-engine/src/main/java/com/netgrif/application/engine/configuration/properties/MigrationProperties.java new file mode 100644 index 00000000000..7716fec4609 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/configuration/properties/MigrationProperties.java @@ -0,0 +1,168 @@ +package com.netgrif.application.engine.configuration.properties; + + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.LinkedHashSet; +import java.util.Set; + + +/** + * Configuration properties class for managing migration-related settings in the application. + * This class is bound to the configuration prefix "nae.migration" and provides various options + * to control the behavior of migration processes, including skipping specific migrations, + * cache eviction, and automatic shutdown after migration completion. + * It also contains nested configuration classes for entity-specific migration settings. + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "nae.migration") +public class MigrationProperties { + + /** + * A list of migration process identifiers or names that should be skipped when applying migration logic. + * This property allows you to configure specific migrations that should be ignored, + * typically useful for excluding unnecessary or problematic migrations. + */ + private Set skip = new LinkedHashSet<>(); + + /** + * Indicates whether caches should be evicted as part of the migration process. + * This property allows enabling or disabling the cache eviction mechanism, which + * is useful in ensuring consistency and up-to-date data during migration operations. + * Default value is {@code true}. + */ + private boolean evictCaches = true; + + /** + * Specifies whether the application should automatically shut down once the migration process is completed. + * This property can be used to terminate the application after the migration, ensuring a clean exit + * if no further operations are intended post-migration. + * Default value is {@code false}. + */ + private boolean shutdownAfterMigration = false; + + /** + * Configuration properties specific to case migration. + * Contains settings that control how cases are migrated, including pagination options. + */ + private CaseMigrationProperties cases = new CaseMigrationProperties(); + + /** + * Configuration properties specific to task migration. + * Contains settings that control how tasks are migrated, including pagination options. + */ + private TaskMigrationProperties tasks = new TaskMigrationProperties(); + + /** + * Configuration properties specific to Petri net migration. + * Contains settings that control how Petri nets are migrated, including pagination options. + */ + private PetriNetMigrationProperties petriNets = new PetriNetMigrationProperties(); + + /** + * Default error handling policy used by migration helpers. + */ + private ErrorPolicy errorPolicy = new ErrorPolicy(); + + /** + * Configuration properties for case-specific migration settings. + * This nested configuration class allows fine-tuning of the case migration process. + */ + @Data + public static class CaseMigrationProperties { + + /** + * The number of cases to process in a single page during migration. + * This controls the batch size for paginated case migration operations. + * Default value is {@code 100}. + */ + private int pageSize = 100; + } + + /** + * Configuration properties for task-specific migration settings. + * This nested configuration class allows fine-tuning of the task migration process. + */ + @Data + public static class TaskMigrationProperties { + + /** + * The number of tasks to process in a single page during migration. + * This controls the batch size for paginated task migration operations. + * Default value is {@code 100}. + */ + private int pageSize = 100; + } + + /** + * Configuration properties for Petri net-specific migration settings. + * This nested configuration class allows fine-tuning of the Petri net migration process. + */ + @Data + public static class PetriNetMigrationProperties { + + /** + * The number of Petri nets to process in a single page during migration. + * This controls the batch size for paginated Petri net migration operations. + * Default value is {@code 100}. + */ + private int pageSize = 100; + } + + /** + * Configuration properties for error handling policy during migration operations. + * This nested configuration class defines how errors encountered during migration helper execution + * should be handled, including whether to throw exceptions immediately, continue processing, + * or apply error thresholds before terminating the migration process. + */ + @Data + public static class ErrorPolicy { + + /** + * Defines the error handling mode for migration helper operations. + * This property controls the behavior when errors are encountered during migration. + *

+ * Supported values: + *

    + *
  • THROW_IMMEDIATELY - Throws an exception as soon as the first error occurs, halting migration immediately.
  • + *
  • CONTINUE - Continues processing despite errors, logging them without interrupting the migration flow.
  • + *
  • THROW_AFTER_LIMIT - Continues processing until the number of errors reaches the threshold specified by {@code maxErrors}, then throws an exception.
  • + *
  • THROW_AFTER_PROCESSING - Completes the entire migration process and throws an exception at the end if any errors were encountered.
  • + *
+ * Default value is {@code "CONTINUE"}. + */ + private String mode = "CONTINUE"; + + /** + * The maximum number of errors allowed before throwing an exception during migration. + * This property is only applicable when the {@code mode} is set to {@code THROW_AFTER_LIMIT}. + * When the number of encountered errors reaches this threshold, an exception will be thrown + * to halt further processing. A value of {@code 0} means no limit is enforced (though this + * effectively makes THROW_AFTER_LIMIT behave like THROW_AFTER_PROCESSING). + * Default value is {@code 0}. + */ + private int maxErrors = 0; + + /** + * Indicates whether errors encountered during migration should be cached for later analysis or processing. + * When enabled, all migration helper errors are stored in memory, allowing developers to review + * and analyze the errors after the migration completes. This is particularly useful for debugging + * and post-migration validation, as it provides a complete error history without interrupting the migration flow. + * Default value is {@code true}. + */ + private boolean cacheErrors = true; + + /** + * Indicates whether the original exception should be rethrown instead of a wrapped exception. + * When set to {@code true}, the migration framework will attempt to rethrow the original exception + * that occurred during helper execution, preserving the original stack trace and exception type. + * When {@code false}, exceptions may be wrapped in a migration-specific exception type. + * This property is useful for maintaining exception transparency and facilitating easier debugging. + * Default value is {@code false}. + */ + private boolean throwOriginal = false; + } +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/utils/MongodbUtils.java b/application-engine/src/main/java/com/netgrif/application/engine/utils/MongodbUtils.java new file mode 100644 index 00000000000..04fa9715c4d --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/utils/MongodbUtils.java @@ -0,0 +1,18 @@ +package com.netgrif.application.engine.utils; + +import com.querydsl.core.types.Predicate; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.BasicQuery; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.repository.support.SpringDataMongodbQuery; + +public final class MongodbUtils { + + private MongodbUtils() { + } + + public static Query toQuery(MongoTemplate mongoTemplate, Class type, Predicate... predicate) { + SpringDataMongodbQuery springDataMongodbQuery = new SpringDataMongodbQuery<>(mongoTemplate, type).where(predicate); + return new BasicQuery(springDataMongodbQuery.asDocument()); + } +} diff --git a/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticSearchTest.groovy b/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticSearchTest.groovy index 7aa17a3f958..e45119d60ec 100644 --- a/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticSearchTest.groovy +++ b/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticSearchTest.groovy @@ -45,7 +45,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @ExtendWith(SpringExtension.class) @ActiveProfiles(["test"]) @SpringBootTest( - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = ApplicationEngine.class ) @AutoConfigureMockMvc diff --git a/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticTaskTest.groovy b/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticTaskTest.groovy index 2ec81d70113..dab94b11d55 100644 --- a/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticTaskTest.groovy +++ b/application-engine/src/test/groovy/com/netgrif/application/engine/elastic/ElasticTaskTest.groovy @@ -40,7 +40,6 @@ import java.util.concurrent.TimeUnit @ExtendWith(SpringExtension.class) @ActiveProfiles(["test"]) @SpringBootTest( - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = ApplicationEngine.class ) @AutoConfigureMockMvc diff --git a/application-engine/src/test/groovy/com/netgrif/application/engine/migration/MigrationTest.groovy b/application-engine/src/test/groovy/com/netgrif/application/engine/migration/MigrationTest.groovy new file mode 100644 index 00000000000..2c53a3bc3da --- /dev/null +++ b/application-engine/src/test/groovy/com/netgrif/application/engine/migration/MigrationTest.groovy @@ -0,0 +1,362 @@ +package com.netgrif.application.engine.migration + +import com.netgrif.application.engine.TestHelper +import com.netgrif.application.engine.adapter.spring.workflow.domain.QCase +import com.netgrif.application.engine.adapter.spring.workflow.domain.QTask +import com.netgrif.application.engine.elastic.domain.ElasticCaseRepository +import com.netgrif.application.engine.migration.model.MigrationError +import com.netgrif.application.engine.migration.model.MigrationErrorPolicy +import com.netgrif.application.engine.migration.throwable.MigrationErrorException +import com.netgrif.application.engine.objects.petrinet.domain.PetriNet +import com.netgrif.application.engine.objects.petrinet.domain.VersionType +import com.netgrif.application.engine.objects.petrinet.domain.roles.ProcessRole +import com.netgrif.application.engine.objects.workflow.domain.Case +import com.netgrif.application.engine.objects.workflow.domain.DataField +import com.netgrif.application.engine.objects.workflow.domain.Task +import com.netgrif.application.engine.objects.workflow.domain.eventoutcomes.petrinetoutcomes.ImportPetriNetEventOutcome +import com.netgrif.application.engine.petrinet.params.ImportPetriNetParams +import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService +import com.netgrif.application.engine.startup.runner.SuperCreatorRunner +import com.netgrif.application.engine.workflow.params.CreateCaseParams +import com.netgrif.application.engine.workflow.service.interfaces.ITaskService +import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService +import groovy.util.logging.Slf4j +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.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.junit.jupiter.SpringExtension + +import static org.junit.jupiter.api.Assertions.assertThrows + +@Slf4j +@SpringBootTest +@ActiveProfiles(["test"]) +@ExtendWith(SpringExtension.class) +class MigrationTest { + + @Autowired + private TestHelper testHelper + + @Autowired + private IPetriNetService petriNetService + + @Autowired + private SuperCreatorRunner superCreator + + @Autowired + private IWorkflowService workflowService + + @Autowired + private ITaskService taskService + + @Autowired + private MigrationHelper migrationHelper + + @Autowired + private ElasticCaseRepository elasticCaseRepository + + private PetriNet netV1, netV2 + + private static final String MIGRATION_TEST_V1 = "petriNets/migration_test_v1.xml" + + private static final String MIGRATION_TEST_V2 = "petriNets/migration_test_v2.xml" + + @BeforeEach + void beforeEach() { + testHelper.truncateDbs() + migrationHelper.clearErrors() + + this.class.classLoader.getResourceAsStream(MIGRATION_TEST_V1).withCloseable { is -> + ImportPetriNetParams importPetriNetParams = ImportPetriNetParams.with() + .xmlFile(is) + .releaseType(VersionType.MAJOR) + .author(superCreator.superUser) + .build() + ImportPetriNetEventOutcome netV1Outcome = petriNetService.importPetriNet(importPetriNetParams) + assert netV1Outcome.getNet() != null + netV1 = netV1Outcome.getNet() + } + + this.class.classLoader.getResourceAsStream(MIGRATION_TEST_V2).withCloseable { is -> + ImportPetriNetParams importPetriNetParams = ImportPetriNetParams.with() + .xmlFile(is) + .releaseType(VersionType.MAJOR) + .author(superCreator.superUser) + .build() + ImportPetriNetEventOutcome netV2Outcome = petriNetService.importPetriNet(importPetriNetParams) + assert netV2Outcome.getNet() != null + netV2 = netV2Outcome.getNet() + } + + (1..10).forEach { + CreateCaseParams caseParams = CreateCaseParams.with() + .processId(netV1.stringId) + .title("Net V1 " + it) + .author(superCreator.superUser) + .locale(Locale.default) + .build() + workflowService.createCase(caseParams) + } + } + + @Test + void migrationHelperShouldMigrateCasesAndReloadTasksThroughFacade() { + List casesBeforeMigration = workflowService.search( + QCase.case$.processIdentifier.eq("migration_test"), + Pageable.ofSize(10) + ).content + + assert casesBeforeMigration.size() == 10 + casesBeforeMigration.each { Case useCase -> + assert !useCase.dataSet.containsKey("income") + assert !useCase.dataSet.containsKey("recreate_info_text") + assert useCase.enabledRoles.isEmpty() + assert useCase.tasks.size() == 1 + assert useCase.tasks[0].transition == "person_info" + } + + migrationHelper.withErrorPolicy(MigrationErrorPolicy.throwAfterProcessing()) { + migrationHelper.updateCasesCursor({ Case useCase -> + migrationHelper.removeCase(useCase) + migrationHelper.updateCasePermissionsFromNet(useCase, netV2) + migrationHelper.reloadTasks(useCase, netV2) + migrationHelper.migratePetriNet(useCase, netV2) + MigrationHelper.addTextDataFields(useCase, [ + "recreate_info_text": "" + ]) + useCase.dataSet["income"] = new DataField(1000) + }, netV1.getObjectId(), 2) + } + + List casesAfterMigration = workflowService.search( + QCase.case$.processIdentifier.eq("migration_test"), + Pageable.ofSize(10) + ).content + + assert casesAfterMigration.size() == 10 + casesAfterMigration.each { Case useCase -> + assert useCase.petriNetObjectId == netV2.objectId + assert useCase.dataSet.containsKey("income") + assert useCase.dataSet["income"].value == 1000 + assert useCase.dataSet.containsKey("recreate_info_text") + assert useCase.enabledRoles.size() == 5 + assert useCase.tasks.size() == 2 + assert useCase.tasks.any { it.transition == "person_info" } + assert useCase.tasks.any { it.transition == "recreate_person" } + } + + assert !migrationHelper.hasErrors() + } + + @Test + void migrationHelperShouldUpdatePetriNetAndApplyCustomTransitionRoleUpdate() { + ProcessRole role = migrationHelper.createRoleInNet( + "migration_test", + "migration_supervisor", + "Migration supervisor" + ) + + Closure updateTransitionRole = migrationHelper.updateTransitionRolesClosure( + "person_info", + "migration_supervisor", + [ + view : true, + perform: true + ] + ) + + migrationHelper.updateNetIgnoreRoles("migration_test", "migration_test_v2.xml", [updateTransitionRole]) + + PetriNet migratedNet = petriNetService.getDefaultVersionByIdentifier("migration_test") + + assert migratedNet.dataSet.containsKey("income") + assert migratedNet.dataSet.containsKey("recreate_info_text") + assert migratedNet.transitions.values().any { it.importId == "recreate_person" } + + ProcessRole migratedRole = migratedNet.roles.values().find { + it.importId == "migration_supervisor" + } + assert migratedRole != null + + def personInfoTransition = migratedNet.transitions.values().find { + it.importId == "person_info" + } + assert personInfoTransition != null + assert personInfoTransition.roles[migratedRole.stringId]["view"] + assert personInfoTransition.roles[migratedRole.stringId]["perform"] + } + + @Test + void migrationHelperShouldUpdateTasksAndAddRoleToExistingTasks() { + ProcessRole role = migrationHelper.createRoleInNet( + "migration_test", + "migration_task_role", + "Migration task role" + ) + + migrationHelper.addRoleToExistingTasks( + role, + netV1, + ["person_info"], + [ + view : true, + perform: true + ] + ) + + Page casePage = workflowService.search( + QCase.case$.processIdentifier.eq("migration_test"), + Pageable.ofSize(10) + ) + + assert casePage.content.size() == 10 + + casePage.content.each { Case useCase -> + useCase.tasks.each { taskPair -> + if (taskPair.transition == "person_info") { + Task task = taskService.findOne(taskPair.task) + assert task.roles.containsKey(role.stringId) + assert task.roles[role.stringId]["view"] + assert task.roles[role.stringId]["perform"] + } + } + } + + migrationHelper.updateTasks( + { Task task -> + task.title.defaultValue = "Migrated task" + }, + QTask.task.transitionId.eq("person_info") + ) + + casePage.content.each { Case useCase -> + useCase.tasks.each { taskPair -> + if (taskPair.transition == "person_info") { + Task task = taskService.findOne(taskPair.task) + assert task.title.defaultValue == "Migrated task" + } + } + } + } + + @Test + void migrationHelperShouldCollectErrorsAndContinueMigration() { + migrationHelper.clearErrors() + + migrationHelper.withErrorPolicy(MigrationErrorPolicy.continueOnError()) { + migrationHelper.updateAllCasesCursor({ Case useCase -> + if (useCase.title.endsWith("1") || useCase.title.endsWith("2")) { + throw new IllegalStateException("Expected migration error for ${useCase.stringId}") + } + + useCase.title = "Successfully migrated" + }, 1) + } + + assert migrationHelper.hasErrors() + + List errors = migrationHelper.popErrors() + assert errors.size() == 2 + assert errors.every { it.message.contains("Failed to prepare migration operation") } + assert !migrationHelper.hasErrors() + + List cases = workflowService.search( + QCase.case$.processIdentifier.eq("migration_test"), + Pageable.ofSize(10) + ).content + + assert cases.count { it.title == "Successfully migrated" } == 8 + } + + @Test + void migrationHelperCollectErrorsShouldClearCacheBeforeAndAfterCollection() { + migrationHelper.clearErrors() + int allCases = workflowService.searchAll(QCase.case$._id.isNotNull()).getContent().size() + + List errors = migrationHelper.collectErrors { + migrationHelper.withErrorPolicy(MigrationErrorPolicy.continueOnError()) { + migrationHelper.updateAllCasesCursor({ Case useCase -> + throw new IllegalStateException("Expected collected error") + }, 1) + } + } + + assert errors.size() == allCases + assert !migrationHelper.hasErrors() + assert errors.every { it.cause instanceof IllegalStateException } + } + + @Test + void updateNetIgnoreRolesShouldMigrateExistingNet() { + migrationHelper.updateNetIgnoreRoles("migration_test", "migration_test_v2.xml") + + def net = petriNetService.getDefaultVersionByIdentifier("migration_test") + + assert net.dataSet.containsKey("income") + assert net.transitions.values().any { it.importId == "recreate_person" } + } + + @Test + void throwImmediately() { + migrationHelper.clearErrors() + + assertThrows(MigrationErrorException) { + migrationHelper.withErrorPolicy(MigrationErrorPolicy.throwImmediately()) { + migrationHelper.updateAllCasesCursor({ Case useCase -> + throw new IllegalStateException("Expected test error") + }, 1) + } + } + + assert migrationHelper.hasErrors() + } + + @Test + void throwAfterLimitIsReached() { + migrationHelper.clearErrors() + + def exception = assertThrows(MigrationErrorException) { + migrationHelper.withErrorPolicy(MigrationErrorPolicy.throwAfterLimit(2)) { + migrationHelper.updateAllCasesCursor({ Case useCase -> + throw new IllegalStateException("Expected test error") + }, 1) + } + } + + assert exception.errors.size() >= 2 + } + + @Test + void throwAfterProcessingFinished() { + migrationHelper.clearErrors() + + def exception = assertThrows(MigrationErrorException) { + migrationHelper.withErrorPolicy(MigrationErrorPolicy.throwAfterProcessing()) { + migrationHelper.updateAllCasesCursor({ Case useCase -> + throw new IllegalStateException("Expected test error") + }, 1) + } + } + + assert exception.errors.size() > 0 + } + + @Test + void continueOnError() { + migrationHelper.clearErrors() + + migrationHelper.withErrorPolicy(MigrationErrorPolicy.continueOnError()) { + migrationHelper.updateAllCasesCursor({ Case useCase -> + throw new IllegalStateException("Expected test error") + }, 1) + } + + assert migrationHelper.hasErrors() + assert migrationHelper.popErrors().size() > 0 + } +} diff --git a/application-engine/src/test/resources/petriNets/migration_test_v1.xml b/application-engine/src/test/resources/petriNets/migration_test_v1.xml new file mode 100644 index 00000000000..86050cce482 --- /dev/null +++ b/application-engine/src/test/resources/petriNets/migration_test_v1.xml @@ -0,0 +1,257 @@ + + migration_test + 1.0.0 + NAE + NAE-2432 + mic + true + true + false + NAE-2432 + + delete_info_text + + <init><h1>Finish this task to delete person.</h1></init> + </data> + <data type="text"> + <id>first_name</id> + <title>First name + John + First name of person + + + last_name + Last name + Doe + Last name of person + + + note + Note + Example note + Notes about this person + + + reset_info_text + + <init><h1>Finish task to reset this person.</h1></init> + </data> + <transition> + <id>delete_person</id> + <x>816</x> + <y>176</y> + <label>Delete person</label> + <icon>delete</icon> + <dataGroup> + <id>delete_person_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>delete_info_text</id> + <logic> + <behavior>visible</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>2</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>htmltextarea</name> + </component> + </dataRef> + </dataGroup> + </transition> + <transition> + <id>person_info</id> + <x>528</x> + <y>176</y> + <label>Person info</label> + <icon>person</icon> + <dataGroup> + <id>t1_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>first_name</id> + <logic> + <behavior>editable</behavior> + <behavior>required</behavior> + <behavior>immediate</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>last_name</id> + <logic> + <behavior>editable</behavior> + <behavior>required</behavior> + <behavior>immediate</behavior> + </logic> + <layout> + <x>2</x> + <y>0</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>note</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>0</x> + <y>1</y> + <rows>2</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>textarea</name> + </component> + </dataRef> + </dataGroup> + </transition> + <transition> + <id>reset_person</id> + <x>816</x> + <y>304</y> + <label>Reset person</label> + <icon>reset_tv</icon> + <dataGroup> + <id>reset_person_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>reset_info_text</id> + <logic> + <behavior>visible</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>2</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>htmltextarea</name> + </component> + </dataRef> + </dataGroup> + </transition> + <place> + <id>p1</id> + <x>336</x> + <y>176</y> + <tokens>1</tokens> + <static>false</static> + </place> + <place> + <id>p2</id> + <x>656</x> + <y>176</y> + <tokens>0</tokens> + <static>false</static> + </place> + <place> + <id>p3</id> + <x>656</x> + <y>304</y> + <tokens>0</tokens> + <static>false</static> + </place> + <place> + <id>p4</id> + <x>976</x> + <y>176</y> + <tokens>1</tokens> + <static>false</static> + </place> + <arc> + <id>a1</id> + <type>regular</type> + <sourceId>p1</sourceId> + <destinationId>person_info</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a2</id> + <type>regular</type> + <sourceId>person_info</sourceId> + <destinationId>p2</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a3</id> + <type>regular</type> + <sourceId>person_info</sourceId> + <destinationId>p3</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a4</id> + <type>regular</type> + <sourceId>p2</sourceId> + <destinationId>delete_person</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a5</id> + <type>regular</type> + <sourceId>p3</sourceId> + <destinationId>reset_person</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a6</id> + <type>regular</type> + <sourceId>delete_person</sourceId> + <destinationId>p4</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a7</id> + <type>regular</type> + <sourceId>reset_person</sourceId> + <destinationId>p1</destinationId> + <multiplicity>1</multiplicity> + <breakpoint> + <x>816</x> + <y>368</y> + </breakpoint> + <breakpoint> + <x>336</x> + <y>368</y> + </breakpoint> + </arc> + <arc> + <id>a8</id> + <type>regular</type> + <sourceId>p2</sourceId> + <destinationId>reset_person</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a9</id> + <type>regular</type> + <sourceId>p3</sourceId> + <destinationId>delete_person</destinationId> + <multiplicity>1</multiplicity> + </arc> +</document> \ No newline at end of file diff --git a/application-engine/src/test/resources/petriNets/migration_test_v2.xml b/application-engine/src/test/resources/petriNets/migration_test_v2.xml new file mode 100644 index 00000000000..1185d2a2476 --- /dev/null +++ b/application-engine/src/test/resources/petriNets/migration_test_v2.xml @@ -0,0 +1,388 @@ +<document xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://petriflow.com/petriflow.schema.xsd"> + <id>migration_test</id> + <version>1.1.0</version> + <initials>NAE</initials> + <title>NAE-2432 + mic + true + true + false + NAE-2432 + + data_editor + Data editor + + + person_creator + Person creator + + + person_recreator + Person recreator + + + person_remover + Person remover + + + person_reseter + Person resetter + + + delete_info_text + + <init><h1>Finish this task to delete person.</h1></init> + </data> + <data type="text"> + <id>first_name</id> + <title>First name + John + First name of person + + + income + Income + Income of person + 1000 + + + last_name + Last name + Doe + Last name of person + + + note + Note + Example note + Notes about this person + + + recreate_info_text + + <init><h1>Finish this task to recreate person.</h1></init> + </data> + <data type="text"> + <id>reset_info_text</id> + <title/> + <init><h1>Finish task to reset this person.</h1></init> + </data> + <transition> + <id>delete_person</id> + <x>816</x> + <y>176</y> + <label>Delete person</label> + <icon>delete</icon> + <roleRef> + <id>person_remover</id> + <logic> + <view>true</view> + <perform>true</perform> + </logic> + </roleRef> + <dataGroup> + <id>delete_person_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>delete_info_text</id> + <logic> + <behavior>visible</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>2</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>htmltextarea</name> + </component> + </dataRef> + </dataGroup> + </transition> + <transition> + <id>person_info</id> + <x>528</x> + <y>176</y> + <label>Person info</label> + <icon>person</icon> + <roleRef> + <id>data_editor</id> + <logic> + <view>true</view> + <perform>true</perform> + </logic> + </roleRef> + <roleRef> + <id>person_creator</id> + <logic> + <view>true</view> + <perform>true</perform> + </logic> + </roleRef> + <dataGroup> + <id>t1_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>first_name</id> + <logic> + <behavior>editable</behavior> + <behavior>required</behavior> + <behavior>immediate</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>last_name</id> + <logic> + <behavior>editable</behavior> + <behavior>required</behavior> + <behavior>immediate</behavior> + </logic> + <layout> + <x>2</x> + <y>0</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>income</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>0</x> + <y>1</y> + <rows>1</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>currency</name> + <property key="fractionSize">2</property> + </component> + </dataRef> + <dataRef> + <id>note</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>0</x> + <y>2</y> + <rows>2</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>textarea</name> + </component> + </dataRef> + </dataGroup> + </transition> + <transition> + <id>recreate_person</id> + <x>976</x> + <y>48</y> + <label>Recreate person</label> + <icon>emergency</icon> + <roleRef> + <id>person_recreator</id> + <logic> + <view>true</view> + <perform>true</perform> + </logic> + </roleRef> + <dataGroup> + <id>recreate_person_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>recreate_info_text</id> + <logic> + <behavior>visible</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>2</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>htmltextarea</name> + </component> + </dataRef> + </dataGroup> + </transition> + <transition> + <id>reset_person</id> + <x>816</x> + <y>304</y> + <label>Reset person</label> + <icon>reset_tv</icon> + <roleRef> + <id>person_reseter</id> + <logic> + <view>true</view> + <perform>true</perform> + </logic> + </roleRef> + <dataGroup> + <id>reset_person_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>reset_info_text</id> + <logic> + <behavior>visible</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>2</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + <component> + <name>htmltextarea</name> + </component> + </dataRef> + </dataGroup> + </transition> + <place> + <id>p1</id> + <x>336</x> + <y>176</y> + <tokens>1</tokens> + <static>false</static> + </place> + <place> + <id>p2</id> + <x>656</x> + <y>176</y> + <tokens>0</tokens> + <static>false</static> + </place> + <place> + <id>p3</id> + <x>656</x> + <y>304</y> + <tokens>0</tokens> + <static>false</static> + </place> + <place> + <id>p4</id> + <x>976</x> + <y>176</y> + <tokens>0</tokens> + <static>false</static> + </place> + <arc> + <id>a1</id> + <type>regular</type> + <sourceId>p1</sourceId> + <destinationId>person_info</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a10</id> + <type>regular</type> + <sourceId>p4</sourceId> + <destinationId>recreate_person</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a11</id> + <type>regular</type> + <sourceId>recreate_person</sourceId> + <destinationId>p1</destinationId> + <multiplicity>1</multiplicity> + <breakpoint> + <x>336</x> + <y>48</y> + </breakpoint> + </arc> + <arc> + <id>a2</id> + <type>regular</type> + <sourceId>person_info</sourceId> + <destinationId>p2</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a3</id> + <type>regular</type> + <sourceId>person_info</sourceId> + <destinationId>p3</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a4</id> + <type>regular</type> + <sourceId>p2</sourceId> + <destinationId>delete_person</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a5</id> + <type>regular</type> + <sourceId>p3</sourceId> + <destinationId>reset_person</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a6</id> + <type>regular</type> + <sourceId>delete_person</sourceId> + <destinationId>p4</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a7</id> + <type>regular</type> + <sourceId>reset_person</sourceId> + <destinationId>p1</destinationId> + <multiplicity>1</multiplicity> + <breakpoint> + <x>816</x> + <y>368</y> + </breakpoint> + <breakpoint> + <x>336</x> + <y>368</y> + </breakpoint> + </arc> + <arc> + <id>a8</id> + <type>regular</type> + <sourceId>p2</sourceId> + <destinationId>reset_person</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a9</id> + <type>regular</type> + <sourceId>p3</sourceId> + <destinationId>delete_person</destinationId> + <multiplicity>1</multiplicity> + </arc> +</document> \ No newline at end of file