From a43660a05acdaab5440522da27d97b0221a9e766 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 27 Mar 2023 15:07:23 -0500 Subject: [PATCH] Manage associations in UpdateAction --- .../core/actions/tables/UpdateAction.java | 129 +++++++++ .../core/actions/tables/UpdateActionTest.java | 273 ++++++++++++++++++ 2 files changed, 402 insertions(+) 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 e77820e3..70c5f2b6 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 @@ -23,15 +23,22 @@ package com.kingsrook.qqq.backend.core.actions.tables; import java.io.Serializable; +import java.util.ArrayList; import java.util.List; +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.automation.AutomationStatus; import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditInput; +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.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; @@ -41,8 +48,13 @@ 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.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -75,6 +87,8 @@ public class UpdateAction UpdateOutput updateResult = qModule.getUpdateInterface().execute(updateInput); // todo post-customization - can do whatever w/ the result if you want + manageAssociations(updateInput); + if(updateInput.getOmitDmlAudit()) { LOG.debug("Requested to omit DML audit"); @@ -89,6 +103,121 @@ public class UpdateAction + /******************************************************************************* + ** + *******************************************************************************/ + private void manageAssociations(UpdateInput updateInput) throws QException + { + QTableMetaData table = updateInput.getTable(); + for(Association association : CollectionUtils.nonNullList(table.getAssociations())) + { + // e.g., order -> orderLine + QTableMetaData associatedTable = QContext.getQInstance().getTable(association.getAssociatedTableName()); + QJoinMetaData join = QContext.getQInstance().getJoin(association.getJoinName()); // todo ... ever need to flip? + // just assume this, at least for now... if(BooleanUtils.isTrue(association.getDoInserts())) + + for(List page : CollectionUtils.getPages(updateInput.getRecords(), 500)) + { + List nextLevelUpdates = new ArrayList<>(); + List nextLevelInserts = new ArrayList<>(); + QQueryFilter findDeletesFilter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + boolean lookForDeletes = false; + + ////////////////////////////////////////////////////// + // for each updated record, look at as associations // + ////////////////////////////////////////////////////// + for(QRecord record : page) + { + if(record.getAssociatedRecords() != null && record.getAssociatedRecords().containsKey(association.getName())) + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // build a sub-query to find the children of this record - and we'll exclude (below) any whose ids are given // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QQueryFilter subFilter = new QQueryFilter(); + findDeletesFilter.addSubFilter(subFilter); + lookForDeletes = true; + List idsBeingUpdated = new ArrayList<>(); + for(JoinOn joinOn : join.getJoinOns()) + { + subFilter.addCriteria(new QFilterCriteria(joinOn.getRightField(), QCriteriaOperator.EQUALS, record.getValue(joinOn.getLeftField()))); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for any associated records present here, figure out if they're being inserted (no primaryKey) or updated // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + for(QRecord associatedRecord : CollectionUtils.nonNullList(record.getAssociatedRecords().get(association.getName()))) + { + Serializable associatedId = associatedRecord.getValue(associatedTable.getPrimaryKeyField()); + if(associatedId == null) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if inserting, add to the inserts list, and propagate values from the header record down to the child // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + for(JoinOn joinOn : join.getJoinOns()) + { + associatedRecord.setValue(joinOn.getRightField(), record.getValue(joinOn.getLeftField())); + } + nextLevelInserts.add(associatedRecord); + } + else + { + /////////////////////////////////////////////////////////////////////////////// + // if updating, add to the updates list, and add the id as one to not delete // + /////////////////////////////////////////////////////////////////////////////// + idsBeingUpdated.add(associatedId); + nextLevelUpdates.add(associatedRecord); + } + } + + if(!idsBeingUpdated.isEmpty()) + { + /////////////////////////////////////////////////////////////////////////////// + // if any records are being updated, add them to the query to NOT be deleted // + /////////////////////////////////////////////////////////////////////////////// + subFilter.addCriteria(new QFilterCriteria(associatedTable.getPrimaryKeyField(), QCriteriaOperator.NOT_IN, idsBeingUpdated)); + } + } + } + + if(lookForDeletes) + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(associatedTable.getName()); + queryInput.setFilter(findDeletesFilter); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + if(!queryOutput.getRecords().isEmpty()) + { + LOG.debug("Deleting associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", queryOutput.getRecords().size())); + DeleteInput deleteInput = new DeleteInput(); + deleteInput.setTableName(association.getAssociatedTableName()); + deleteInput.setPrimaryKeys(queryOutput.getRecords().stream().map(r -> r.getValue(associatedTable.getPrimaryKeyField())).collect(Collectors.toList())); + DeleteOutput deleteOutput = new DeleteAction().execute(deleteInput); + } + } + + if(CollectionUtils.nullSafeHasContents(nextLevelUpdates)) + { + LOG.debug("Updating associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", nextLevelUpdates.size())); + UpdateInput nextLevelUpdateInput = new UpdateInput(); + nextLevelUpdateInput.setTableName(association.getAssociatedTableName()); + nextLevelUpdateInput.setRecords(nextLevelUpdates); + UpdateOutput nextLevelUpdateOutput = new UpdateAction().execute(nextLevelUpdateInput); + } + + if(CollectionUtils.nullSafeHasContents(nextLevelInserts)) + { + LOG.debug("Inserting associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", nextLevelUpdates.size())); + InsertInput nextLevelInsertInput = new InsertInput(); + nextLevelInsertInput.setTableName(association.getAssociatedTableName()); + nextLevelInsertInput.setRecords(nextLevelInserts); + InsertOutput nextLevelInsertOutput = new InsertAction().execute(nextLevelInsertInput); + } + } + } + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionTest.java index e03729c1..dc2e0e4a 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionTest.java @@ -25,12 +25,18 @@ package com.kingsrook.qqq.backend.core.actions.tables; import java.util.ArrayList; import java.util.List; import com.kingsrook.qqq.backend.core.BaseTest; +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.insert.InsertInput; 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.utils.TestUtils; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -60,4 +66,271 @@ class UpdateActionTest extends BaseTest assertNotNull(result); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateAssociationsUpdateOneChild() throws QException + { + QInstance qInstance = QContext.getQInstance(); + QContext.getQSession().withSecurityKeyValue("storeId", 1); + + insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations(); + + ////////////////////////////////////////////////////////////////////// + // update the order's orderNo, and the quantity on one of the lines // + ////////////////////////////////////////////////////////////////////// + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_ORDER); + updateInput.setRecords(List.of( + new QRecord().withValue("id", 1).withValue("orderNo", "ORD123-b") + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 1).withValue("quantity", 17)) + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 2)) + )); + new UpdateAction().execute(updateInput); + + List orders = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER); + assertEquals(2, orders.size()); + assertEquals("ORD123-b", orders.get(0).getValueString("orderNo")); + + List orderLines = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM); + assertEquals(3, orderLines.size()); + assertEquals(17, orderLines.get(0).getValueInteger("quantity")); + + List lineItemExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + assertEquals(3, lineItemExtrinsics.size()); + + List orderExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER_EXTRINSIC); + assertEquals(4, orderExtrinsics.size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateAssociationsUpdateOneGrandChild() throws QException + { + QInstance qInstance = QContext.getQInstance(); + QContext.getQSession().withSecurityKeyValue("storeId", 1); + + insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations(); + + ////////////////////////////////////////////////////////////////////// + // update the order's orderNo, and the quantity on one of the lines // + ////////////////////////////////////////////////////////////////////// + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_ORDER); + updateInput.setRecords(List.of( + new QRecord().withValue("id", 1).withValue("orderNo", "ORD123-b") + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 1) + .withAssociatedRecord("extrinsics", new QRecord().withValue("id", 1).withValue("value", "LINE-VAL-1-updated"))) + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 2)) + )); + new UpdateAction().execute(updateInput); + + List orders = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER); + assertEquals(2, orders.size()); + assertEquals("ORD123-b", orders.get(0).getValueString("orderNo")); + + List orderLines = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM); + assertEquals(3, orderLines.size()); + + List lineItemExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + assertEquals(3, lineItemExtrinsics.size()); + assertEquals("LINE-VAL-1-updated", lineItemExtrinsics.get(0).getValueString("value")); + + List orderExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER_EXTRINSIC); + assertEquals(4, orderExtrinsics.size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateAssociationsDeleteOneChild() throws QException + { + QInstance qInstance = QContext.getQInstance(); + QContext.getQSession().withSecurityKeyValue("storeId", 1); + + insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations(); + + ////////////////////////////////////////////////////////////////////// + // update the order's orderNo, and the quantity on one of the lines // + ////////////////////////////////////////////////////////////////////// + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_ORDER); + updateInput.setRecords(List.of( + new QRecord().withValue("id", 1).withValue("orderNo", "ORD123-b") + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 2)) + )); + new UpdateAction().execute(updateInput); + + List orders = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER); + assertEquals(2, orders.size()); + assertEquals("ORD123-b", orders.get(0).getValueString("orderNo")); + + List orderLines = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM); + assertEquals(2, orderLines.size()); + assertTrue(orderLines.stream().noneMatch(r -> r.getValueInteger("id").equals(1))); // id=1 should be deleted + + List lineItemExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + assertEquals(2, lineItemExtrinsics.size()); // one was deleted (when its parent was deleted) + + List orderExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER_EXTRINSIC); + assertEquals(4, orderExtrinsics.size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateAssociationsDeleteGrandchildren() throws QException + { + QInstance qInstance = QContext.getQInstance(); + QContext.getQSession().withSecurityKeyValue("storeId", 1); + + insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations(); + + ////////////////////////////////////////////////////////////////////// + // update the order's orderNo, and the quantity on one of the lines // + ////////////////////////////////////////////////////////////////////// + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_ORDER); + updateInput.setRecords(List.of( + new QRecord().withValue("id", 1).withValue("orderNo", "ORD123-b") + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 1)) + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 2).withAssociatedRecords("extrinsics", new ArrayList<>())) + )); + new UpdateAction().execute(updateInput); + + List orders = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER); + assertEquals(2, orders.size()); + assertEquals("ORD123-b", orders.get(0).getValueString("orderNo")); + + List orderLines = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM); + assertEquals(3, orderLines.size()); + + List lineItemExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + assertEquals(1, lineItemExtrinsics.size()); // deleted the two beneath line item id=2 + + List orderExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER_EXTRINSIC); + assertEquals(4, orderExtrinsics.size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateAssociationsInsertOneChild() throws QException + { + QInstance qInstance = QContext.getQInstance(); + QContext.getQSession().withSecurityKeyValue("storeId", 1); + + insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations(); + + ////////////////////////////////////////////////////////////////////// + // update the order's orderNo, and the quantity on one of the lines // + ////////////////////////////////////////////////////////////////////// + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_ORDER); + updateInput.setRecords(List.of( + new QRecord().withValue("id", 1).withValue("orderNo", "ORD123-b") + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 1)) + .withAssociatedRecord("orderLine", new QRecord().withValue("id", 2)) + .withAssociatedRecord("orderLine", new QRecord().withValue("sku", "BASIC4").withValue("quantity", 47)) + )); + new UpdateAction().execute(updateInput); + + List orders = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER); + assertEquals(2, orders.size()); + assertEquals("ORD123-b", orders.get(0).getValueString("orderNo")); + + List orderLines = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM); + assertEquals(4, orderLines.size()); + assertEquals("BASIC4", orderLines.get(3).getValueString("sku")); + assertEquals(47, orderLines.get(3).getValueInteger("quantity")); + + List lineItemExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + assertEquals(3, lineItemExtrinsics.size()); + + List orderExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER_EXTRINSIC); + assertEquals(4, orderExtrinsics.size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdateAssociationsDeleteAllChildren() throws QException + { + QInstance qInstance = QContext.getQInstance(); + QContext.getQSession().withSecurityKeyValue("storeId", 1); + + insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations(); + + ////////////////////////////////////////////////////////////////////// + // update the order's orderNo, and the quantity on one of the lines // + ////////////////////////////////////////////////////////////////////// + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(TestUtils.TABLE_NAME_ORDER); + updateInput.setRecords(List.of( + new QRecord().withValue("id", 1).withAssociatedRecords("orderLine", new ArrayList<>()), + new QRecord().withValue("id", 2).withAssociatedRecords("orderLine", new ArrayList<>()) + )); + new UpdateAction().execute(updateInput); + + List orders = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER); + assertEquals(2, orders.size()); + + List lineItemExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + assertEquals(0, lineItemExtrinsics.size()); + + List orderLines = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_LINE_ITEM); + assertEquals(0, orderLines.size()); // all of these got deleted too. + + List orderExtrinsics = TestUtils.queryTable(qInstance, TestUtils.TABLE_NAME_ORDER_EXTRINSIC); + assertEquals(4, orderExtrinsics.size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void insert2OrdersWith3Lines3LineExtrinsicsAnd4OrderExtrinsicAssociations() throws QException + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_ORDER); + insertInput.setRecords(List.of( + new QRecord().withValue("storeId", 1).withValue("orderNo", "ORD123") + + .withAssociatedRecord("orderLine", new QRecord().withValue("sku", "BASIC1").withValue("quantity", 1) + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "LINE-EXT-1.1").withValue("value", "LINE-VAL-1"))) + + .withAssociatedRecord("orderLine", new QRecord().withValue("sku", "BASIC2").withValue("quantity", 2) + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "LINE-EXT-2.1").withValue("value", "LINE-VAL-2")) + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "LINE-EXT-2.2").withValue("value", "LINE-VAL-3"))) + + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "MY-FIELD-1").withValue("value", "MY-VALUE-1")) + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "MY-FIELD-2").withValue("value", "MY-VALUE-2")) + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "MY-FIELD-3").withValue("value", "MY-VALUE-3")), + + new QRecord().withValue("storeId", 1).withValue("orderNo", "ORD124") + .withAssociatedRecord("orderLine", new QRecord().withValue("sku", "BASIC3").withValue("quantity", 3)) + .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "YOUR-FIELD-1").withValue("value", "YOUR-VALUE-1")) + )); + new InsertAction().execute(insertInput); + } }