From 265847e01ace88a4252e902f9836d1ef13566b76 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 8 May 2023 15:06:28 -0500 Subject: [PATCH] Completed first round implementation of {pre,post}{insert,delete} actions --- .../AbstractPostDeleteCustomizer.java | 16 ++ .../AbstractPostInsertCustomizer.java | 11 + .../AbstractPostUpdateCustomizer.java | 17 +- .../AbstractPreDeleteCustomizer.java | 18 ++ .../AbstractPreInsertCustomizer.java | 11 + .../AbstractPreUpdateCustomizer.java | 14 ++ .../core/actions/tables/DeleteAction.java | 172 ++++++++++++-- .../core/actions/tables/InsertAction.java | 47 +++- .../core/actions/tables/UpdateAction.java | 126 ++++++---- .../actions/tables/delete/DeleteOutput.java | 49 ++++ .../AbstractPostDeleteCustomizerTest.java | 119 ++++++++++ .../AbstractPostUpdateCustomizerTest.java | 215 ++++++++++++++++++ .../AbstractPreDeleteCustomizerTest.java | 118 ++++++++++ .../AbstractPreUpdateCustomizerTest.java | 116 +++++++++- .../qqq/backend/core/utils/TestUtils.java | 13 +- 15 files changed, 978 insertions(+), 84 deletions(-) create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizerTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizerTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizerTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizer.java index 2ce2a177..7ba804a2 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizer.java @@ -28,7 +28,23 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* + ** Abstract class that a table can specify an implementation of, to provide + ** custom actions after a delete takes place. ** + ** General implementation would be, to iterate over the records (ones which didn't + ** have a delete error), and look at their values: + ** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records? + ** - possibly throwing an exception - though doing so won't stop the delete, and instead + ** will just set a warning on all of the deleted records... + ** - doing "whatever else" you may want to do. + ** - returning the list of records (can be the input list) that you want to go back + ** to the caller - this is how errors and warnings are propagated . + ** + ** Note that the full deleteInput is available as a field in this class. + ** + ** A future enhancement here may be to take (as fields in this class) the list of + ** records that the delete action marked in error - the user might want to do + ** something special with them (idk, try some other way to delete them?) *******************************************************************************/ public abstract class AbstractPostDeleteCustomizer { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostInsertCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostInsertCustomizer.java index ac7d195f..325349d6 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostInsertCustomizer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostInsertCustomizer.java @@ -28,7 +28,18 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* + ** Abstract class that a table can specify an implementation of, to provide + ** custom actions after an insert takes place. ** + ** General implementation would be, to iterate over the records (the outputs of + ** the insert action), and look at their values: + ** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records + ** - possibly throwing an exception - though doing so won't stop the update, and instead + ** will just set a warning on all of the updated records... + ** - doing "whatever else" you may want to do. + ** - returning the list of records (can be the input list) that you want to go back to the caller. + ** + ** Note that the full insertInput is available as a field in this class. *******************************************************************************/ public abstract class AbstractPostInsertCustomizer { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizer.java index e3658ec0..a8d7f7f2 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizer.java @@ -26,12 +26,27 @@ import java.io.Serializable; import java.util.HashMap; import java.util.List; import java.util.Map; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* + ** Abstract class that a table can specify an implementation of, to provide + ** custom actions after an update takes place. ** + ** General implementation would be, to iterate over the records (the outputs of + ** the update action), and look at their values: + ** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records? + ** - possibly throwing an exception - though doing so won't stop the update, and instead + ** will just set a warning on all of the updated records... + ** - doing "whatever else" you may want to do. + ** - returning the list of records (can be the input list) that you want to go back to the caller. + ** + ** Note that the full updateInput is available as a field in this class, and the + ** "old records" (e.g., with values freshly fetched from the backend) will be + ** available (if the backend supports it) - both as a list (`getOldRecordList`) + ** and as a memoized (by this class) map of primaryKey to record (`getOldRecordMap`). *******************************************************************************/ public abstract class AbstractPostUpdateCustomizer { @@ -45,7 +60,7 @@ public abstract class AbstractPostUpdateCustomizer /******************************************************************************* ** *******************************************************************************/ - public abstract List apply(List records); + public abstract List apply(List records) throws QException; diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizer.java index 7fa93003..de03076f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizer.java @@ -28,7 +28,24 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* + ** Abstract class that a table can specify an implementation of, to provide + ** custom actions before a delete takes place. ** + ** General implementation would be, to iterate over the records (which the DeleteAction + ** would look up based on the inputs to the delete action), and look at their values: + ** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records + ** - possibly throwing an exception - if you really don't want the delete operation to continue. + ** - doing "whatever else" you may want to do. + ** - returning the list of records (can be the input list) - this is how errors + ** and warnings are propagated to the DeleteAction. Note that any records with + ** an error will NOT proceed to the backend's delete interface - but those with + ** warnings will. + ** + ** Note that the full deleteInput is available as a field in this class. + ** + ** A future enhancement here may be to take (as fields in this class) the list of + ** records that the delete action marked in error - the user might want to do + ** something special with them (idk, try some other way to delete them?) *******************************************************************************/ public abstract class AbstractPreDeleteCustomizer { @@ -62,4 +79,5 @@ public abstract class AbstractPreDeleteCustomizer { this.deleteInput = deleteInput; } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreInsertCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreInsertCustomizer.java index 1b3ab771..daf89cb0 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreInsertCustomizer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreInsertCustomizer.java @@ -28,7 +28,18 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* + ** Abstract class that a table can specify an implementation of, to provide + ** custom actions before an insert takes place. ** + ** General implementation would be, to iterate over the records (the inputs to + ** the insert action), and look at their values: + ** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records + ** - possibly manipulating values (`setValue`) + ** - possibly throwing an exception - if you really don't want the insert operation to continue. + ** - doing "whatever else" you may want to do. + ** - returning the list of records (can be the input list) that you want to go on to the backend implementation class. + ** + ** Note that the full insertInput is available as a field in this class. *******************************************************************************/ public abstract class AbstractPreInsertCustomizer { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizer.java index ca464f08..b166d414 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizer.java @@ -31,7 +31,21 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* + ** Abstract class that a table can specify an implementation of, to provide + ** custom actions before an update takes place. ** + ** General implementation would be, to iterate over the records (the inputs to + ** the update action), and look at their values: + ** - possibly adding Errors (`addError`) or Warnings (`addWarning`) to the records + ** - possibly manipulating values (`setValue`) + ** - possibly throwing an exception - if you really don't want the update operation to continue. + ** - doing "whatever else" you may want to do. + ** - returning the list of records (can be the input list) that you want to go on to the backend implementation class. + ** + ** Note that the full updateInput is available as a field in this class, and the + ** "old records" (e.g., with values freshly fetched from the backend) will be + ** available (if the backend supports it) - both as a list (`getOldRecordList`) + ** and as a memoized (by this class) map of primaryKey to record (`getOldRecordMap`). *******************************************************************************/ public abstract class AbstractPreUpdateCustomizer { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java index b7f10a70..2555586f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java @@ -30,8 +30,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import com.kingsrook.qqq.backend.core.actions.ActionHelper; import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostDeleteCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; @@ -48,7 +50,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; @@ -78,17 +79,24 @@ public class DeleteAction { ActionHelper.validateSession(deleteInput); - QTableMetaData table = deleteInput.getTable(); - - QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); - QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(deleteInput.getBackend()); - DeleteInterface deleteInterface = qModule.getDeleteInterface(); + QTableMetaData table = deleteInput.getTable(); + String primaryKeyField = table.getPrimaryKeyField(); if(CollectionUtils.nullSafeHasContents(deleteInput.getPrimaryKeys()) && deleteInput.getQueryFilter() != null) { throw (new QException("A delete request may not contain both a list of primary keys and a query filter.")); } + ////////////////////////////////////////////////////// + // load the backend module and its delete interface // + ////////////////////////////////////////////////////// + QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); + QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(deleteInput.getBackend()); + DeleteInterface deleteInterface = qModule.getDeleteInterface(); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if there's a query filter, but the interface doesn't support using a query filter, then do a query for the filter, to get a list of primary keys instead // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(deleteInput.getQueryFilter() != null && !deleteInterface.supportsQueryFilterInput()) { LOG.info("Querying for primary keys, for backend module " + qModule.getBackendType() + " which does not support queryFilter input for deletes"); @@ -105,16 +113,62 @@ public class DeleteAction } } - List recordListForAudit = deleteInterface.supportsPreFetchQuery() ? getRecordListForAuditIfNeeded(deleteInput) : new ArrayList<>(); - List recordsWithValidationErrors = deleteInterface.supportsPreFetchQuery() ? validateRecordsExistAndCanBeAccessed(deleteInput, recordListForAudit) : new ArrayList<>(); + //////////////////////////////////////////////////////////////////////////////// + // fetch the old list of records (if the backend supports it), for audits, // + // for "not-found detection", and for the pre-action to use (if there is one) // + //////////////////////////////////////////////////////////////////////////////// + Optional> oldRecordList = fetchOldRecords(deleteInput, deleteInterface); - Optional preDeleteCustomizer = QCodeLoader.getTableCustomizer(AbstractPreDeleteCustomizer.class, table, TableCustomizers.PRE_DELETE_RECORD.getRole()); - if(preDeleteCustomizer.isPresent()) + List recordsWithValidationErrors = new ArrayList<>(); + List recordsWithValidationWarnings = new ArrayList<>(); + if(oldRecordList.isPresent()) { - preDeleteCustomizer.get().setDeleteInput(deleteInput); - preDeleteCustomizer.get().apply(null); // todo monday + recordsWithValidationErrors = validateRecordsExistAndCanBeAccessed(deleteInput, oldRecordList.get()); } + /////////////////////////////////////////////////////////////////////////// + // after all validations, run the pre-delete customizer, if there is one // + /////////////////////////////////////////////////////////////////////////// + Optional preDeleteCustomizer = QCodeLoader.getTableCustomizer(AbstractPreDeleteCustomizer.class, table, TableCustomizers.PRE_DELETE_RECORD.getRole()); + if(preDeleteCustomizer.isPresent() && oldRecordList.isPresent()) + { + //////////////////////////////////////////////////////////////////////////// + // make list of records that are still good - to pass into the customizer // + //////////////////////////////////////////////////////////////////////////// + List recordsForCustomizer = makeListOfRecordsNotInErrorList(primaryKeyField, oldRecordList.get(), recordsWithValidationErrors); + + preDeleteCustomizer.get().setDeleteInput(deleteInput); + List customizerResult = preDeleteCustomizer.get().apply(recordsForCustomizer); + + /////////////////////////////////////////////////////// + // check if any records got errors in the customizer // + /////////////////////////////////////////////////////// + Set primaryKeysToRemoveFromInput = new HashSet<>(); + for(QRecord record : customizerResult) + { + if(CollectionUtils.nullSafeHasContents(record.getErrors())) + { + recordsWithValidationErrors.add(record); + primaryKeysToRemoveFromInput.add(record.getValue(primaryKeyField)); + } + else + { + recordsWithValidationWarnings.add(record); + } + } + + ///////////////////////////////////////////////////////////////// + // do one mass removal of any bad keys from the input key list // + ///////////////////////////////////////////////////////////////// + if(!primaryKeysToRemoveFromInput.isEmpty()) + { + deleteInput.getPrimaryKeys().removeAll(primaryKeysToRemoveFromInput); + } + } + + //////////////////////////////////// + // have the backend do the delete // + //////////////////////////////////// DeleteOutput deleteOutput = deleteInterface.execute(deleteInput); ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -126,18 +180,94 @@ public class DeleteAction deleteOutput.setRecordsWithErrors(new ArrayList<>()); outputRecordsWithErrors = deleteOutput.getRecordsWithErrors(); } - outputRecordsWithErrors.addAll(recordsWithValidationErrors); + List outputRecordsWithWarnings = deleteOutput.getRecordsWithWarnings(); + if(outputRecordsWithWarnings == null) + { + deleteOutput.setRecordsWithWarnings(new ArrayList<>()); + outputRecordsWithWarnings = deleteOutput.getRecordsWithWarnings(); + } + outputRecordsWithWarnings.addAll(recordsWithValidationWarnings); + + //////////////////////////////////////// + // delete associations, if applicable // + //////////////////////////////////////// manageAssociations(deleteInput); - new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(deleteInput).withRecordList(recordListForAudit)); + /////////////////////////////////// + // do the audit // + // todo - add input.omitDmlAudit // + /////////////////////////////////// + DMLAuditInput dmlAuditInput = new DMLAuditInput().withTableActionInput(deleteInput); + oldRecordList.ifPresent(l -> dmlAuditInput.setRecordList(l)); + new DMLAuditAction().execute(dmlAuditInput); + + ///////////////////////////////////////////////////////////// + // finally, run the pre-delete customizer, if there is one // + ///////////////////////////////////////////////////////////// + Optional postDeleteCustomizer = QCodeLoader.getTableCustomizer(AbstractPostDeleteCustomizer.class, table, TableCustomizers.POST_DELETE_RECORD.getRole()); + if(postDeleteCustomizer.isPresent() && oldRecordList.isPresent()) + { + //////////////////////////////////////////////////////////////////////////// + // make list of records that are still good - to pass into the customizer // + //////////////////////////////////////////////////////////////////////////// + List recordsForCustomizer = makeListOfRecordsNotInErrorList(primaryKeyField, oldRecordList.get(), outputRecordsWithErrors); + + try + { + postDeleteCustomizer.get().setDeleteInput(deleteInput); + List customizerResult = postDeleteCustomizer.get().apply(recordsForCustomizer); + + /////////////////////////////////////////////////////// + // check if any records got errors in the customizer // + /////////////////////////////////////////////////////// + for(QRecord record : customizerResult) + { + if(CollectionUtils.nullSafeHasContents(record.getErrors())) + { + outputRecordsWithErrors.add(record); + } + else if(CollectionUtils.nullSafeHasContents(record.getWarnings())) + { + outputRecordsWithWarnings.add(record); + } + } + } + catch(Exception e) + { + for(QRecord record : recordsForCustomizer) + { + record.addWarning("An error occurred after the delete: " + e.getMessage()); + outputRecordsWithWarnings.add(record); + } + } + } return deleteOutput; } + /******************************************************************************* + ** + *******************************************************************************/ + private static List makeListOfRecordsNotInErrorList(String primaryKeyField, List oldRecordList, List outputRecordsWithErrors) + { + Map recordsWithErrorsMap = outputRecordsWithErrors.stream().collect(Collectors.toMap(r -> r.getValue(primaryKeyField), r -> r)); + List recordsForCustomizer = new ArrayList<>(); + for(QRecord record : oldRecordList) + { + if(!recordsWithErrorsMap.containsKey(record.getValue(primaryKeyField))) + { + recordsForCustomizer.add(record); + } + } + return recordsForCustomizer; + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -183,12 +313,9 @@ public class DeleteAction /******************************************************************************* ** *******************************************************************************/ - private static List getRecordListForAuditIfNeeded(DeleteInput deleteInput) throws QException + private static Optional> fetchOldRecords(DeleteInput deleteInput, DeleteInterface deleteInterface) throws QException { - List recordListForAudit = null; - - AuditLevel auditLevel = DMLAuditAction.getAuditLevel(deleteInput); - if(AuditLevel.RECORD.equals(auditLevel) || AuditLevel.FIELD.equals(auditLevel)) + if(deleteInterface.supportsPreFetchQuery()) { List primaryKeyList = deleteInput.getPrimaryKeys(); if(CollectionUtils.nullSafeIsEmpty(deleteInput.getPrimaryKeys()) && deleteInput.getQueryFilter() != null) @@ -198,19 +325,16 @@ public class DeleteAction if(CollectionUtils.nullSafeHasContents(primaryKeyList)) { - //////////////////////////////////////////////////////////////////////////////////// - // always fetch the records - we'll use them anyway for checking not-exist below // - //////////////////////////////////////////////////////////////////////////////////// QueryInput queryInput = new QueryInput(); queryInput.setTransaction(deleteInput.getTransaction()); queryInput.setTableName(deleteInput.getTableName()); queryInput.setFilter(new QQueryFilter(new QFilterCriteria(deleteInput.getTable().getPrimaryKeyField(), QCriteriaOperator.IN, primaryKeyList))); QueryOutput queryOutput = new QueryAction().execute(queryInput); - recordListForAudit = queryOutput.getRecords(); + return (Optional.of(queryOutput.getRecords())); } } - return (recordListForAudit); + return (Optional.empty()); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java index 8eb01301..fd19ba27 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java @@ -41,6 +41,7 @@ import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCust import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.interfaces.InsertInterface; import com.kingsrook.qqq.backend.core.actions.tables.helpers.UniqueKeyHelper; import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; @@ -89,13 +90,23 @@ public class InsertAction extends AbstractQActionFunction preInsertCustomizer = QCodeLoader.getTableCustomizer(AbstractPreInsertCustomizer.class, table, TableCustomizers.PRE_INSERT_RECORD.getRole()); if(preInsertCustomizer.isPresent()) { @@ -103,15 +114,28 @@ public class InsertAction extends AbstractQActionFunction errors = insertOutput.getRecords().stream().flatMap(r -> r.getErrors().stream()).toList(); + //////////////////////////////////// + // have the backend do the insert // + //////////////////////////////////// + InsertOutput insertOutput = insertInterface.execute(insertInput); + + ////////////////////////////// + // log if there were errors // + ////////////////////////////// + List errors = insertOutput.getRecords().stream().flatMap(r -> r.getErrors().stream()).toList(); if(CollectionUtils.nullSafeHasContents(errors)) { LOG.warn("Errors in insertAction", logPair("tableName", table.getName()), logPair("errorCount", errors.size()), errors.size() < 10 ? logPair("errors", errors) : logPair("first10Errors", errors.subList(0, 10))); } + ////////////////////////////////////////////////// + // insert any associations in the input records // + ////////////////////////////////////////////////// manageAssociations(table, insertOutput.getRecords(), insertInput.getTransaction()); + ////////////////// + // do the audit // + ////////////////// if(insertInput.getOmitDmlAudit()) { LOG.debug("Requested to omit DML audit"); @@ -121,11 +145,24 @@ public class InsertAction extends AbstractQActionFunction postInsertCustomizer = QCodeLoader.getTableCustomizer(AbstractPostInsertCustomizer.class, table, TableCustomizers.POST_INSERT_RECORD.getRole()); if(postInsertCustomizer.isPresent()) { - postInsertCustomizer.get().setInsertInput(insertInput); - insertOutput.setRecords(postInsertCustomizer.get().apply(insertOutput.getRecords())); + try + { + postInsertCustomizer.get().setInsertInput(insertInput); + insertOutput.setRecords(postInsertCustomizer.get().apply(insertOutput.getRecords())); + } + catch(Exception e) + { + for(QRecord record : insertOutput.getRecords()) + { + record.addWarning("An error occurred after the insert: " + e.getMessage()); + } + } } return insertOutput; diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java index 68efb3ce..0598f57c 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java @@ -57,7 +57,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; @@ -92,57 +91,98 @@ public class UpdateAction QTableMetaData table = updateInput.getTable(); - ValueBehaviorApplier.applyFieldBehaviors(updateInput.getInstance(), table, updateInput.getRecords()); - + ////////////////////////////////////////////////////// + // load the backend module and its update interface // + ////////////////////////////////////////////////////// QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(updateInput.getBackend()); UpdateInterface updateInterface = qModule.getUpdateInterface(); - List oldRecordList = updateInterface.supportsPreFetchQuery() ? getOldRecordListForAuditIfNeeded(updateInput) : new ArrayList<>(); + //////////////////////////////////////////////////////////////////////////////// + // fetch the old list of records (if the backend supports it), for audits, // + // for "not-found detection", and for the pre-action to use (if there is one) // + //////////////////////////////////////////////////////////////////////////////// + Optional> oldRecordList = fetchOldRecords(updateInput, updateInterface); + ///////////////////////////// + // run standard validators // + ///////////////////////////// + ValueBehaviorApplier.applyFieldBehaviors(updateInput.getInstance(), table, updateInput.getRecords()); validatePrimaryKeysAreGiven(updateInput); - if(updateInterface.supportsPreFetchQuery()) + if(oldRecordList.isPresent()) { - validateRecordsExistAndCanBeAccessed(updateInput, oldRecordList); + validateRecordsExistAndCanBeAccessed(updateInput, oldRecordList.get()); } validateRequiredFields(updateInput); ValidateRecordSecurityLockHelper.validateSecurityFields(table, updateInput.getRecords(), ValidateRecordSecurityLockHelper.Action.UPDATE); + /////////////////////////////////////////////////////////////////////////// + // after all validations, run the pre-update customizer, if there is one // + /////////////////////////////////////////////////////////////////////////// Optional preUpdateCustomizer = QCodeLoader.getTableCustomizer(AbstractPreUpdateCustomizer.class, table, TableCustomizers.PRE_UPDATE_RECORD.getRole()); if(preUpdateCustomizer.isPresent()) { preUpdateCustomizer.get().setUpdateInput(updateInput); - preUpdateCustomizer.get().setOldRecordList(oldRecordList); + oldRecordList.ifPresent(l -> preUpdateCustomizer.get().setOldRecordList(l)); updateInput.setRecords(preUpdateCustomizer.get().apply(updateInput.getRecords())); } + //////////////////////////////////// + // have the backend do the update // + //////////////////////////////////// UpdateOutput updateOutput = updateInterface.execute(updateInput); + ////////////////////////////// + // log if there were errors // + ////////////////////////////// List errors = updateOutput.getRecords().stream().flatMap(r -> r.getErrors().stream()).toList(); if(CollectionUtils.nullSafeHasContents(errors)) { LOG.warn("Errors in updateAction", logPair("tableName", updateInput.getTableName()), logPair("errorCount", errors.size()), errors.size() < 10 ? logPair("errors", errors) : logPair("first10Errors", errors.subList(0, 10))); } + ///////////////////////////////////////////////////////////////////////////////////// + // update (inserting and deleting as needed) any associations in the input records // + ///////////////////////////////////////////////////////////////////////////////////// manageAssociations(updateInput); + ////////////////// + // do the audit // + ////////////////// if(updateInput.getOmitDmlAudit()) { LOG.debug("Requested to omit DML audit"); } else { - new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(updateInput).withRecordList(updateOutput.getRecords()).withOldRecordList(oldRecordList)); + DMLAuditInput dmlAuditInput = new DMLAuditInput() + .withTableActionInput(updateInput) + .withRecordList(updateOutput.getRecords()); + oldRecordList.ifPresent(l -> dmlAuditInput.setOldRecordList(l)); + new DMLAuditAction().execute(dmlAuditInput); } + ///////////////////////////////////////////////////////////// + // finally, run the pre-update customizer, if there is one // + ///////////////////////////////////////////////////////////// Optional postUpdateCustomizer = QCodeLoader.getTableCustomizer(AbstractPostUpdateCustomizer.class, table, TableCustomizers.POST_UPDATE_RECORD.getRole()); if(postUpdateCustomizer.isPresent()) { - postUpdateCustomizer.get().setUpdateInput(updateInput); - postUpdateCustomizer.get().setOldRecordList(oldRecordList); - updateOutput.setRecords(postUpdateCustomizer.get().apply(updateOutput.getRecords())); + try + { + postUpdateCustomizer.get().setUpdateInput(updateInput); + oldRecordList.ifPresent(l -> postUpdateCustomizer.get().setOldRecordList(l)); + updateOutput.setRecords(postUpdateCustomizer.get().apply(updateOutput.getRecords())); + } + catch(Exception e) + { + for(QRecord record : updateOutput.getRecords()) + { + record.addWarning("An error occurred after the update: " + e.getMessage()); + } + } } return updateOutput; @@ -150,6 +190,31 @@ public class UpdateAction + /******************************************************************************* + ** + *******************************************************************************/ + private Optional> fetchOldRecords(UpdateInput updateInput, UpdateInterface updateInterface) throws QException + { + if(updateInterface.supportsPreFetchQuery()) + { + String primaryKeyField = updateInput.getTable().getPrimaryKeyField(); + List pkeysBeingUpdated = CollectionUtils.nonNullList(updateInput.getRecords()).stream().map(r -> r.getValue(primaryKeyField)).toList(); + + QueryInput queryInput = new QueryInput(); + queryInput.setTransaction(updateInput.getTransaction()); + queryInput.setTableName(updateInput.getTableName()); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria(primaryKeyField, QCriteriaOperator.IN, pkeysBeingUpdated))); + // todo - need a limit? what if too many?? + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + return (Optional.of(queryOutput.getRecords())); + } + + return (Optional.empty()); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -383,45 +448,6 @@ public class UpdateAction - /******************************************************************************* - ** - *******************************************************************************/ - private static List getOldRecordListForAuditIfNeeded(UpdateInput updateInput) - { - if(updateInput.getOmitDmlAudit()) - { - return (null); - } - - try - { - AuditLevel auditLevel = DMLAuditAction.getAuditLevel(updateInput); - List oldRecordList = null; - if(AuditLevel.FIELD.equals(auditLevel)) - { - String primaryKeyField = updateInput.getTable().getPrimaryKeyField(); - List pkeysBeingUpdated = CollectionUtils.nonNullList(updateInput.getRecords()).stream().map(r -> r.getValue(primaryKeyField)).toList(); - - QueryInput queryInput = new QueryInput(); - queryInput.setTransaction(updateInput.getTransaction()); - queryInput.setTableName(updateInput.getTableName()); - queryInput.setFilter(new QQueryFilter(new QFilterCriteria(primaryKeyField, QCriteriaOperator.IN, pkeysBeingUpdated))); - // todo - need a limit? what if too many?? - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - oldRecordList = queryOutput.getRecords(); - } - return oldRecordList; - } - catch(Exception e) - { - LOG.warn("Error getting old record list for audit", e, logPair("table", updateInput.getTableName())); - return (null); - } - } - - - /******************************************************************************* ** If the table being updated uses an automation-status field, populate it now. *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteOutput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteOutput.java index 9516272f..bb1afe6a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteOutput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteOutput.java @@ -37,6 +37,7 @@ public class DeleteOutput extends AbstractActionOutput implements Serializable { private int deletedRecordCount = 0; private List recordsWithErrors; + private List recordsWithWarnings; @@ -81,6 +82,7 @@ public class DeleteOutput extends AbstractActionOutput implements Serializable } + /******************************************************************************* ** *******************************************************************************/ @@ -94,6 +96,7 @@ public class DeleteOutput extends AbstractActionOutput implements Serializable } + /******************************************************************************* ** *******************************************************************************/ @@ -101,4 +104,50 @@ public class DeleteOutput extends AbstractActionOutput implements Serializable { deletedRecordCount += i; } + + + + /******************************************************************************* + ** Getter for recordsWithWarnings + *******************************************************************************/ + public List getRecordsWithWarnings() + { + return (this.recordsWithWarnings); + } + + + + /******************************************************************************* + ** Setter for recordsWithWarnings + *******************************************************************************/ + public void setRecordsWithWarnings(List recordsWithWarnings) + { + this.recordsWithWarnings = recordsWithWarnings; + } + + + + /******************************************************************************* + ** Fluent setter for recordsWithWarnings + *******************************************************************************/ + public DeleteOutput withRecordsWithWarnings(List recordsWithWarnings) + { + this.recordsWithWarnings = recordsWithWarnings; + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void addRecordWithWarning(QRecord recordWithWarning) + { + if(this.recordsWithWarnings == null) + { + this.recordsWithWarnings = new ArrayList<>(); + } + this.recordsWithWarnings.add(recordWithWarning); + } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizerTest.java new file mode 100644 index 00000000..411b671d --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostDeleteCustomizerTest.java @@ -0,0 +1,119 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.actions.customizers; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostDeleteCustomizer + *******************************************************************************/ +class AbstractPostDeleteCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + QTableMetaData table = QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + table.withCustomizer(TableCustomizers.POST_DELETE_RECORD.getRole(), new QCodeReference(AbstractPostDeleteCustomizerTest.PostDelete.class)); + + TestUtils.insertRecords(table, List.of( + new QRecord().withValue("id", 1).withValue("firstName", "Homer"), + new QRecord().withValue("id", 2).withValue("firstName", "Marge"), + new QRecord().withValue("id", 3).withValue("firstName", "Bart") + )); + + //////////////////////////////////////////////////////// + // try a delete that the post-customizer should reject // + //////////////////////////////////////////////////////// + { + DeleteInput deleteInput = new DeleteInput(); + deleteInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + deleteInput.setPrimaryKeys(List.of(1, 2)); + DeleteOutput deleteOutput = new DeleteAction().execute(deleteInput); + assertEquals(0, deleteOutput.getRecordsWithErrors().size()); + assertEquals(1, deleteOutput.getRecordsWithWarnings().size()); + assertEquals(1, deleteOutput.getRecordsWithWarnings().get(0).getValue("id")); + assertEquals(2, deleteOutput.getDeletedRecordCount()); + assertEquals("You shouldn't have deleted Homer...", deleteOutput.getRecordsWithWarnings().get(0).getWarnings().get(0)); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertNull(getOutput.getRecord()); + + getInput.setPrimaryKey(2); + getOutput = new GetAction().execute(getInput); + assertNull(getOutput.getRecord()); + } + + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static class PostDelete extends AbstractPostDeleteCustomizer + { + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) + { + for(QRecord record : records) + { + if(record.getValue("firstName").equals("Homer")) + { + record.addWarning("You shouldn't have deleted Homer..."); + } + } + + return (records); + } + + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizerTest.java new file mode 100644 index 00000000..faa63377 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPostUpdateCustomizerTest.java @@ -0,0 +1,215 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.actions.customizers; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Unit test for AbstractPreUpdateCustomizer + *******************************************************************************/ +class AbstractPostUpdateCustomizerTest extends BaseTest +{ + private static final String NAME_CHANGES_TABLE = "nameChanges"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + QInstance qInstance = QContext.getQInstance(); + + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD.getRole(), new QCodeReference(PostUpdate.class)); + + qInstance.addTable(new QTableMetaData() + .withBackendName(TestUtils.MEMORY_BACKEND_NAME) + .withName(NAME_CHANGES_TABLE) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("personId", QFieldType.INTEGER)) + .withField(new QFieldMetaData("message", QFieldType.STRING))); + + TestUtils.insertRecords(table, List.of( + new QRecord().withValue("id", 1).withValue("firstName", "Homer"), + new QRecord().withValue("id", 2).withValue("firstName", "Marge"), + new QRecord().withValue("id", 3).withValue("firstName", "Bart") + )); + + /////////////////////////////////////////////////////////////////////////////// + // try an update where the post-update customizer will insert another record // + /////////////////////////////////////////////////////////////////////////////// + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + updateInput.setRecords(List.of(new QRecord().withValue("id", 1).withValue("firstName", "Homer J."))); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + assertTrue(CollectionUtils.nullSafeIsEmpty(updateOutput.getRecords().get(0).getErrors())); + + GetInput getInput = new GetInput(); + getInput.setTableName(NAME_CHANGES_TABLE); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals(1, getOutput.getRecord().getValueInteger("personId")); + assertEquals("Changed first name from [Homer] to [Homer J.]", getOutput.getRecord().getValueString("message")); + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // try an update where the post-update customizer will issue a warning (though will have updated the record too) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + updateInput.setRecords(List.of(new QRecord().withValue("id", 1).withValue("firstName", "Warning"))); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + assertTrue(CollectionUtils.nullSafeIsEmpty(updateOutput.getRecords().get(0).getErrors())); + assertTrue(updateOutput.getRecords().get(0).getWarnings().stream().anyMatch(s -> s.contains("updated to a warning value"))); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("Warning", getOutput.getRecord().getValueString("firstName")); + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // try an update where the post-update customizer will throw an error (resulting in an updated record with a warning) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + updateInput.setRecords(List.of(new QRecord().withValue("id", 1).withValue("firstName", "throw"))); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + assertTrue(CollectionUtils.nullSafeIsEmpty(updateOutput.getRecords().get(0).getErrors())); + assertTrue(updateOutput.getRecords().get(0).getWarnings().stream().anyMatch(s -> s.contains("Forced Exception"))); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("throw", getOutput.getRecord().getValueString("firstName")); + } + + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static class PostUpdate extends AbstractPostUpdateCustomizer + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) throws QException + { + List nameChangeRecordsToInsert = new ArrayList<>(); + + for(QRecord record : records) + { + boolean recordHadError = CollectionUtils.nullSafeHasContents(record.getErrors()); + boolean inputRecordHadFirstName = record.getValues().containsKey("firstName"); + boolean inputRecordHadLastName = record.getValues().containsKey("lastName"); + + if(recordHadError) + { + continue; + } + + if(inputRecordHadFirstName) + { + QRecord oldRecord = getOldRecordMap().get(record.getValue("id")); + if(oldRecord != null && oldRecord.getValue("firstName") != null) + { + nameChangeRecordsToInsert.add(new QRecord() + .withValue("personId", record.getValue("id")) + .withValue("message", "Changed first name from [" + oldRecord.getValueString("firstName") + "] to [" + record.getValueString("firstName") + "]") + ); + } + + if("warning".equalsIgnoreCase(record.getValueString("firstName"))) + { + record.addWarning("Record was updated to a warning value"); + } + + if("throw".equalsIgnoreCase(record.getValueString("firstName"))) + { + throw (new QException("Forced Exception")); + } + } + + if(inputRecordHadLastName) + { + QRecord oldRecord = getOldRecordMap().get(record.getValue("id")); + if(oldRecord != null && oldRecord.getValue("lastName") != null) + { + nameChangeRecordsToInsert.add(new QRecord() + .withValue("personId", record.getValue("id")) + .withValue("message", "Changed last name from [" + oldRecord.getValueString("lastName") + "] to [" + record.getValueString("lastName") + "]") + ); + } + } + } + + if(CollectionUtils.nullSafeHasContents(nameChangeRecordsToInsert)) + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(NAME_CHANGES_TABLE); + insertInput.setRecords(nameChangeRecordsToInsert); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + } + + return (records); + } + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizerTest.java new file mode 100644 index 00000000..ca07fa0e --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreDeleteCustomizerTest.java @@ -0,0 +1,118 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.actions.customizers; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for AbstractPreDeleteCustomizer + *******************************************************************************/ +class AbstractPreDeleteCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + QTableMetaData table = QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD.getRole(), new QCodeReference(PreDelete.class)); + + TestUtils.insertRecords(table, List.of( + new QRecord().withValue("id", 1).withValue("firstName", "Homer"), + new QRecord().withValue("id", 2).withValue("firstName", "Marge"), + new QRecord().withValue("id", 3).withValue("firstName", "Bart") + )); + + //////////////////////////////////////////////////////// + // try a delete that the pre-customizer should reject // + //////////////////////////////////////////////////////// + { + DeleteInput deleteInput = new DeleteInput(); + deleteInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + deleteInput.setPrimaryKeys(List.of(1, 2)); + DeleteOutput deleteOutput = new DeleteAction().execute(deleteInput); + assertEquals(1, deleteOutput.getRecordsWithErrors().size()); + assertEquals(0, deleteOutput.getRecordsWithWarnings().size()); + assertEquals(1, deleteOutput.getRecordsWithErrors().get(0).getValue("id")); + assertEquals(1, deleteOutput.getDeletedRecordCount()); + assertEquals("You may not delete a Homer.", deleteOutput.getRecordsWithErrors().get(0).getErrors().get(0)); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("Homer", getOutput.getRecord().getValueString("firstName")); + + getInput.setPrimaryKey(2); + getOutput = new GetAction().execute(getInput); + assertNull(getOutput.getRecord()); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static class PreDelete extends AbstractPreDeleteCustomizer + { + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) + { + for(QRecord record : records) + { + if(record.getValue("firstName").equals("Homer")) + { + record.addError("You may not delete a Homer."); + } + } + + return (records); + } + + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizerTest.java index 7a91d76a..cc26afe8 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizerTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/AbstractPreUpdateCustomizerTest.java @@ -24,12 +24,21 @@ package com.kingsrook.qqq.backend.core.actions.customizers; import java.util.List; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.TestUtils; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -42,10 +51,74 @@ class AbstractPreUpdateCustomizerTest extends BaseTest ** *******************************************************************************/ @Test - void test() + void test() throws QException { QTableMetaData table = QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD.getRole(), new QCodeReference(PreUpdate.class)); + + TestUtils.insertRecords(table, List.of( + new QRecord().withValue("id", 1).withValue("firstName", "Homer"), + new QRecord().withValue("id", 2).withValue("firstName", "Marge"), + new QRecord().withValue("id", 3).withValue("firstName", "Bart") + )); + + ///////////////////////////////////////////////////////// + // try an update that the pre-customizer should reject // + ///////////////////////////////////////////////////////// + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + updateInput.setRecords(List.of(new QRecord().withValue("id", 1).withValue("firstName", "--"))); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + assertTrue(updateOutput.getRecords().get(0).getErrors().stream().anyMatch(s -> s.contains("must contain at least one letter"))); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + getInput.setPrimaryKey(1); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("Homer", getOutput.getRecord().getValueString("firstName")); + } + + ////////////////////////////////////////////// + // try an update that gets its data changed // + ////////////////////////////////////////////// + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + updateInput.setRecords(List.of(new QRecord().withValue("id", 2).withValue("firstName", "Ms."))); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + assertTrue(updateOutput.getRecords().get(0).getErrors().isEmpty()); + assertEquals("Ms.", updateOutput.getRecords().get(0).getValueString("firstName")); + assertEquals("Simpson", updateOutput.getRecords().get(0).getValueString("lastName")); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + getInput.setPrimaryKey(2); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("Ms.", getOutput.getRecord().getValueString("firstName")); + assertEquals("Simpson", getOutput.getRecord().getValueString("lastName")); + } + + ////////////////////////////////////////////////////////////////////////// + // try an update that uses data from the previous version of the record // + ////////////////////////////////////////////////////////////////////////// + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + updateInput.setRecords(List.of(new QRecord().withValue("id", 3).withValue("lastName", "Simpson"))); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + assertTrue(updateOutput.getRecords().get(0).getErrors().isEmpty()); + assertEquals("BART", updateOutput.getRecords().get(0).getValueString("firstName")); + assertEquals("Simpson", updateOutput.getRecords().get(0).getValueString("lastName")); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY); + getInput.setPrimaryKey(3); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("BART", getOutput.getRecord().getValueString("firstName")); + assertEquals("Simpson", getOutput.getRecord().getValueString("lastName")); + } + } @@ -53,7 +126,7 @@ class AbstractPreUpdateCustomizerTest extends BaseTest /******************************************************************************* ** *******************************************************************************/ - private static class PreUpdate extends AbstractPreUpdateCustomizer + public static class PreUpdate extends AbstractPreUpdateCustomizer { /******************************************************************************* ** @@ -61,7 +134,44 @@ class AbstractPreUpdateCustomizerTest extends BaseTest @Override public List apply(List records) { - return null; + for(QRecord record : records) + { + boolean inputRecordHadFirstName = record.getValues().containsKey("firstName"); + boolean inputRecordHadLastName = record.getValues().containsKey("lastName"); + + if(inputRecordHadFirstName) + { + //////////////////////////////////////////////////////////////// + // if updating first name, give an error if it has no letters // + //////////////////////////////////////////////////////////////// + if(!record.getValueString("firstName").matches(".*\\w.*")) + { + record.addError("First name must contain at least one letter."); + } + + ////////////////////////////////////////////////////////////// + // if setting firstname to Ms., update last name to Simpson // + ////////////////////////////////////////////////////////////// + if(record.getValueString("firstName").equals("Ms.")) + { + record.setValue("lastName", "Simpson"); + } + } + + ////////////////////////////////////////////////////////////////////////// + // if updating the person's last name, set their first name to all caps // + ////////////////////////////////////////////////////////////////////////// + if(inputRecordHadLastName) + { + QRecord oldRecord = getOldRecordMap().get(record.getValue("id")); + if(oldRecord != null && oldRecord.getValue("firstName") != null) + { + record.setValue("firstName", oldRecord.getValueString("firstName").toUpperCase()); + } + } + } + + return (records); } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java index e259c199..2eb4f1c0 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java @@ -276,7 +276,7 @@ public class TestUtils /******************************************************************************* ** *******************************************************************************/ - public static void insertRecords(QInstance qInstance, QTableMetaData table, List records) throws QException + public static void insertRecords(QTableMetaData table, List records) throws QException { InsertInput insertInput = new InsertInput(); insertInput.setTableName(table.getName()); @@ -286,6 +286,17 @@ public class TestUtils + /******************************************************************************* + ** + *******************************************************************************/ + @Deprecated + public static void insertRecords(QInstance qInstance, QTableMetaData table, List records) throws QException + { + insertRecords(table, records); + } + + + /******************************************************************************* ** *******************************************************************************/