diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java index 9ded0791..e9816912 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java @@ -194,7 +194,7 @@ public class DMLAuditAction extends AbstractQActionFunction primaryKeyList = getPrimaryKeysFromQueryFilter(deleteInput); deleteInput.setPrimaryKeys(primaryKeyList); + primaryKeys = primaryKeyList; if(primaryKeyList.isEmpty()) { @@ -165,10 +168,22 @@ public class DeleteAction if(!primaryKeysToRemoveFromInput.isEmpty()) { - primaryKeys.removeAll(primaryKeysToRemoveFromInput); + if(primaryKeys == null) + { + LOG.warn("There were primary keys to remove from the input, but no primary key list (filter supplied as input?)", new LogPair("primaryKeysToRemoveFromInput", primaryKeysToRemoveFromInput)); + } + else + { + primaryKeys.removeAll(primaryKeysToRemoveFromInput); + } } } + //////////////////////////////////////////////////////////////////////////////////////////////// + // stash a copy of primary keys that didn't have errors (for use in manageAssociations below) // + //////////////////////////////////////////////////////////////////////////////////////////////// + Set primaryKeysWithoutErrors = new HashSet<>(CollectionUtils.nonNullList(primaryKeys)); + //////////////////////////////////// // have the backend do the delete // //////////////////////////////////// @@ -187,11 +202,13 @@ public class DeleteAction /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if a record had a validation warning, but then an execution error, remove it from the warning list - so it's only in one of them. // + // also, always remove from /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// for(QRecord outputRecordWithError : outputRecordsWithErrors) { Serializable pkey = outputRecordWithError.getValue(primaryKeyFieldName); recordsWithValidationWarnings.remove(pkey); + primaryKeysWithoutErrors.remove(pkey); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -211,15 +228,23 @@ public class DeleteAction //////////////////////////////////////// // delete associations, if applicable // //////////////////////////////////////// - manageAssociations(deleteInput); + manageAssociations(primaryKeysWithoutErrors, deleteInput); - /////////////////////////////////// - // do the audit // - // todo - add input.omitDmlAudit // - /////////////////////////////////// - DMLAuditInput dmlAuditInput = new DMLAuditInput().withTableActionInput(deleteInput); - oldRecordList.ifPresent(l -> dmlAuditInput.setRecordList(l)); - new DMLAuditAction().execute(dmlAuditInput); + ////////////////// + // do the audit // + ////////////////// + if(deleteInput.getOmitDmlAudit()) + { + LOG.debug("Requested to omit DML audit"); + } + else + { + DMLAuditInput dmlAuditInput = new DMLAuditInput() + .withTableActionInput(deleteInput) + .withAuditContext(deleteInput.getAuditContext()); + oldRecordList.ifPresent(l -> dmlAuditInput.setRecordList(l)); + new DMLAuditAction().execute(dmlAuditInput); + } ////////////////////////////////////////////////////////////// // finally, run the post-delete customizer, if there is one // @@ -340,7 +365,7 @@ public class DeleteAction /******************************************************************************* ** *******************************************************************************/ - private void manageAssociations(DeleteInput deleteInput) throws QException + private void manageAssociations(Set primaryKeysWithoutErrors, DeleteInput deleteInput) throws QException { QTableMetaData table = deleteInput.getTable(); for(Association association : CollectionUtils.nonNullList(table.getAssociations())) @@ -353,7 +378,7 @@ public class DeleteAction if(join.getJoinOns().size() == 1 && join.getJoinOns().get(0).getLeftField().equals(table.getPrimaryKeyField())) { - filter.addCriteria(new QFilterCriteria(join.getJoinOns().get(0).getRightField(), QCriteriaOperator.IN, deleteInput.getPrimaryKeys())); + filter.addCriteria(new QFilterCriteria(join.getJoinOns().get(0).getRightField(), QCriteriaOperator.IN, new ArrayList<>(primaryKeysWithoutErrors))); } else { 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 62871c8e..0b5adcb0 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 @@ -155,7 +155,10 @@ public class InsertAction extends AbstractQActionFunction. + */ + +package com.kingsrook.qqq.backend.core.actions.tables; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.tables.helpers.UniqueKeyHelper; +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.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; +import com.kingsrook.qqq.backend.core.model.actions.tables.replace.ReplaceInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.replace.ReplaceOutput; +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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; + + +/******************************************************************************* + ** Action to do a "replace" - e.g: Update rows with unique-key values that are + ** already in the table; insert rows whose unique keys weren't already in the + ** table, and delete rows that weren't in the input (all based on a + ** UniqueKey that's part of the input) + ** + ** Note - the filter in the ReplaceInput - its role is to limit what rows are + ** potentially deleted. e.g., if you have a table that's segmented, and you're + ** only replacing a particular segment of it (say, for 1 client), then you pass + ** in a filter that finds rows matching that segment. See Test for example. + *******************************************************************************/ +public class ReplaceAction extends AbstractQActionFunction +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public ReplaceOutput execute(ReplaceInput input) throws QException + { + ReplaceOutput output = new ReplaceOutput(); + + QBackendTransaction transaction = input.getTransaction(); + boolean weOwnTheTransaction = false; + + try + { + QTableMetaData table = input.getTable(); + UniqueKey uniqueKey = input.getKey(); + String primaryKeyField = table.getPrimaryKeyField(); + if(transaction == null) + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(input.getTableName()); + transaction = new InsertAction().openTransaction(insertInput); + weOwnTheTransaction = true; + } + + List insertList = new ArrayList<>(); + List updateList = new ArrayList<>(); + List primaryKeysToKeep = new ArrayList<>(); + + for(List page : CollectionUtils.getPages(input.getRecords(), 1000)) + { + /////////////////////////////////////////////////////////////////////////////////// + // originally it was thought that we'd need to pass the filter in here // + // but, it's been decided not to. the filter only applies to what we can delete // + /////////////////////////////////////////////////////////////////////////////////// + Map, Serializable> existingKeys = UniqueKeyHelper.getExistingKeys(transaction, table, page, uniqueKey); + for(QRecord record : page) + { + Optional> keyValues = UniqueKeyHelper.getKeyValues(table, uniqueKey, record); + if(keyValues.isPresent()) + { + if(existingKeys.containsKey(keyValues.get())) + { + Serializable primaryKey = existingKeys.get(keyValues.get()); + record.setValue(primaryKeyField, primaryKey); + updateList.add(record); + primaryKeysToKeep.add(primaryKey); + } + else + { + insertList.add(record); + } + } + } + } + + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(table.getName()); + insertInput.setRecords(insertList); + insertInput.setTransaction(transaction); + insertInput.setOmitDmlAudit(input.getOmitDmlAudit()); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + primaryKeysToKeep.addAll(insertOutput.getRecords().stream().map(r -> r.getValue(primaryKeyField)).toList()); + output.setInsertOutput(insertOutput); + + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(table.getName()); + updateInput.setRecords(updateList); + updateInput.setTransaction(transaction); + updateInput.setOmitDmlAudit(input.getOmitDmlAudit()); + UpdateOutput updateOutput = new UpdateAction().execute(updateInput); + output.setUpdateOutput(updateOutput); + + QQueryFilter deleteFilter = new QQueryFilter(new QFilterCriteria(primaryKeyField, QCriteriaOperator.NOT_IN, primaryKeysToKeep)); + if(input.getFilter() != null) + { + deleteFilter.addSubFilter(input.getFilter()); + } + + DeleteInput deleteInput = new DeleteInput(); + deleteInput.setTableName(table.getName()); + deleteInput.setQueryFilter(deleteFilter); + deleteInput.setTransaction(transaction); + deleteInput.setOmitDmlAudit(input.getOmitDmlAudit()); + DeleteOutput deleteOutput = new DeleteAction().execute(deleteInput); + output.setDeleteOutput(deleteOutput); + + if(weOwnTheTransaction) + { + transaction.commit(); + } + + return (output); + } + catch(Exception e) + { + if(weOwnTheTransaction) + { + transaction.rollback(); + } + throw (new QException("Error executing replace action", e)); + } + finally + { + if(weOwnTheTransaction) + { + transaction.close(); + } + } + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/count/CountInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/count/CountInput.java index 1a4863e5..b6383d99 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/count/CountInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/count/CountInput.java @@ -51,6 +51,17 @@ public class CountInput extends AbstractTableActionInput + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public CountInput(String tableName) + { + setTableName(tableName); + } + + + /******************************************************************************* ** Getter for filter ** @@ -152,4 +163,15 @@ public class CountInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Fluent setter for filter + *******************************************************************************/ + public CountInput withFilter(QQueryFilter filter) + { + this.filter = filter; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java index c39ed3ec..3945246e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java @@ -43,6 +43,9 @@ public class DeleteInput extends AbstractTableActionInput private QQueryFilter queryFilter; private InputSource inputSource = QInputSource.SYSTEM; + private boolean omitDmlAudit = false; + private String auditContext = null; + /******************************************************************************* @@ -211,4 +214,66 @@ public class DeleteInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for omitDmlAudit + *******************************************************************************/ + public boolean getOmitDmlAudit() + { + return (this.omitDmlAudit); + } + + + + /******************************************************************************* + ** Setter for omitDmlAudit + *******************************************************************************/ + public void setOmitDmlAudit(boolean omitDmlAudit) + { + this.omitDmlAudit = omitDmlAudit; + } + + + + /******************************************************************************* + ** Fluent setter for omitDmlAudit + *******************************************************************************/ + public DeleteInput withOmitDmlAudit(boolean omitDmlAudit) + { + this.omitDmlAudit = omitDmlAudit; + return (this); + } + + + + /******************************************************************************* + ** Getter for auditContext + *******************************************************************************/ + public String getAuditContext() + { + return (this.auditContext); + } + + + + /******************************************************************************* + ** Setter for auditContext + *******************************************************************************/ + public void setAuditContext(String auditContext) + { + this.auditContext = auditContext; + } + + + + /******************************************************************************* + ** Fluent setter for auditContext + *******************************************************************************/ + public DeleteInput withAuditContext(String auditContext) + { + this.auditContext = auditContext; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java index 1e154dd8..4e750818 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java @@ -46,6 +46,7 @@ public class InsertInput extends AbstractTableActionInput private boolean skipUniqueKeyCheck = false; private boolean omitDmlAudit = false; + private String auditContext = null; @@ -284,4 +285,35 @@ public class InsertInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for auditContext + *******************************************************************************/ + public String getAuditContext() + { + return (this.auditContext); + } + + + + /******************************************************************************* + ** Setter for auditContext + *******************************************************************************/ + public void setAuditContext(String auditContext) + { + this.auditContext = auditContext; + } + + + + /******************************************************************************* + ** Fluent setter for auditContext + *******************************************************************************/ + public InsertInput withAuditContext(String auditContext) + { + this.auditContext = auditContext; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java new file mode 100644 index 00000000..fe09cf5e --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java @@ -0,0 +1,210 @@ +/* + * 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.model.actions.tables.replace; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class ReplaceInput extends AbstractTableActionInput +{ + private QBackendTransaction transaction; + private UniqueKey key; + private List records; + private QQueryFilter filter; + + private boolean omitDmlAudit = false; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public ReplaceInput() + { + } + + + + /******************************************************************************* + ** Getter for transaction + *******************************************************************************/ + public QBackendTransaction getTransaction() + { + return (this.transaction); + } + + + + /******************************************************************************* + ** Setter for transaction + *******************************************************************************/ + public void setTransaction(QBackendTransaction transaction) + { + this.transaction = transaction; + } + + + + /******************************************************************************* + ** Fluent setter for transaction + *******************************************************************************/ + public ReplaceInput withTransaction(QBackendTransaction transaction) + { + this.transaction = transaction; + return (this); + } + + + + /******************************************************************************* + ** Getter for records + *******************************************************************************/ + public List getRecords() + { + return (this.records); + } + + + + /******************************************************************************* + ** Setter for records + *******************************************************************************/ + public void setRecords(List records) + { + this.records = records; + } + + + + /******************************************************************************* + ** Fluent setter for records + *******************************************************************************/ + public ReplaceInput withRecords(List records) + { + this.records = records; + return (this); + } + + + + /******************************************************************************* + ** Getter for filter + *******************************************************************************/ + public QQueryFilter getFilter() + { + return (this.filter); + } + + + + /******************************************************************************* + ** Setter for filter + *******************************************************************************/ + public void setFilter(QQueryFilter filter) + { + this.filter = filter; + } + + + + /******************************************************************************* + ** Fluent setter for filter + *******************************************************************************/ + public ReplaceInput withFilter(QQueryFilter filter) + { + this.filter = filter; + return (this); + } + + + + /******************************************************************************* + ** Getter for key + *******************************************************************************/ + public UniqueKey getKey() + { + return (this.key); + } + + + + /******************************************************************************* + ** Setter for key + *******************************************************************************/ + public void setKey(UniqueKey key) + { + this.key = key; + } + + + + /******************************************************************************* + ** Fluent setter for key + *******************************************************************************/ + public ReplaceInput withKey(UniqueKey key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************* + ** Getter for omitDmlAudit + *******************************************************************************/ + public boolean getOmitDmlAudit() + { + return (this.omitDmlAudit); + } + + + + /******************************************************************************* + ** Setter for omitDmlAudit + *******************************************************************************/ + public void setOmitDmlAudit(boolean omitDmlAudit) + { + this.omitDmlAudit = omitDmlAudit; + } + + + + /******************************************************************************* + ** Fluent setter for omitDmlAudit + *******************************************************************************/ + public ReplaceInput withOmitDmlAudit(boolean omitDmlAudit) + { + this.omitDmlAudit = omitDmlAudit; + return (this); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceOutput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceOutput.java new file mode 100644 index 00000000..95c6f649 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceOutput.java @@ -0,0 +1,133 @@ +/* + * 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.model.actions.tables.replace; + + +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class ReplaceOutput extends AbstractActionOutput +{ + private InsertOutput insertOutput; + private UpdateOutput updateOutput; + private DeleteOutput deleteOutput; + + + + /******************************************************************************* + ** Getter for insertOutput + *******************************************************************************/ + public InsertOutput getInsertOutput() + { + return (this.insertOutput); + } + + + + /******************************************************************************* + ** Setter for insertOutput + *******************************************************************************/ + public void setInsertOutput(InsertOutput insertOutput) + { + this.insertOutput = insertOutput; + } + + + + /******************************************************************************* + ** Fluent setter for insertOutput + *******************************************************************************/ + public ReplaceOutput withInsertOutput(InsertOutput insertOutput) + { + this.insertOutput = insertOutput; + return (this); + } + + + + /******************************************************************************* + ** Getter for updateOutput + *******************************************************************************/ + public UpdateOutput getUpdateOutput() + { + return (this.updateOutput); + } + + + + /******************************************************************************* + ** Setter for updateOutput + *******************************************************************************/ + public void setUpdateOutput(UpdateOutput updateOutput) + { + this.updateOutput = updateOutput; + } + + + + /******************************************************************************* + ** Fluent setter for updateOutput + *******************************************************************************/ + public ReplaceOutput withUpdateOutput(UpdateOutput updateOutput) + { + this.updateOutput = updateOutput; + return (this); + } + + + + /******************************************************************************* + ** Getter for deleteOutput + *******************************************************************************/ + public DeleteOutput getDeleteOutput() + { + return (this.deleteOutput); + } + + + + /******************************************************************************* + ** Setter for deleteOutput + *******************************************************************************/ + public void setDeleteOutput(DeleteOutput deleteOutput) + { + this.deleteOutput = deleteOutput; + } + + + + /******************************************************************************* + ** Fluent setter for deleteOutput + *******************************************************************************/ + public ReplaceOutput withDeleteOutput(DeleteOutput deleteOutput) + { + this.deleteOutput = deleteOutput; + return (this); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/CollectionUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/CollectionUtils.java index f817b6b3..ecb5aca5 100755 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/CollectionUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/CollectionUtils.java @@ -627,4 +627,40 @@ public class CollectionUtils } } + + + /******************************************************************************* + ** Take a multi-level map, e.g., Map{String, Map{Integer, BigDecimal}} + ** and invert it - e.g., to Map{Integer, Map{String, BigDecimal}} + *******************************************************************************/ + public static Map> swapMultiLevelMapKeys(Map> input) + { + if(input == null) + { + return (null); + } + + Map> output = new HashMap<>(); + + for(Map.Entry> entry : input.entrySet()) + { + K1 key1 = entry.getKey(); + Map map1 = entry.getValue(); + + if(map1 != null) + { + for(Map.Entry entry2 : map1.entrySet()) + { + K2 key2 = entry2.getKey(); + V value = entry2.getValue(); + + output.computeIfAbsent(key2, (k) -> new HashMap<>()); + output.get(key2).put(key1, value); + } + } + } + + return (output); + } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java index 8492eff3..076a3f39 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java @@ -25,11 +25,15 @@ package com.kingsrook.qqq.backend.core.actions.tables; import java.util.List; import java.util.Objects; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; 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.count.CountInput; 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.QFilterOrderBy; @@ -41,6 +45,10 @@ 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.audits.AuditLevel; import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules; +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.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage; import com.kingsrook.qqq.backend.core.utils.TestUtils; import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder; import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; @@ -399,4 +407,87 @@ class DeleteActionTest extends BaseTest new InsertAction().execute(insertInput); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDeleteWithErrorsDoesntDeleteAssociations() throws QException + { + QContext.getQSession().setSecurityKeyValues(MapBuilder.of(TestUtils.SECURITY_KEY_TYPE_STORE, ListBuilder.of(1))); + + QTableMetaData table = QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(OrderPreDeleteCustomizer.class)); + + //////////////////////////////////////////////////////////////////////////////////////////////// + // insert 2 orders - one that will fail to delete, and one that will warn, but should delete. // + //////////////////////////////////////////////////////////////////////////////////////////////// + InsertOutput insertOutput = new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_ORDER) + .withRecords(List.of( + new QRecord().withValue("id", OrderPreDeleteCustomizer.DELETE_ERROR_ID).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"))), + + new QRecord().withValue("id", OrderPreDeleteCustomizer.DELETE_WARN_ID).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")) + ))); + + /////////////////////////// + // confirm insert counts // + /////////////////////////// + assertEquals(2, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER)).getCount()); + assertEquals(2, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_LINE_ITEM)).getCount()); + assertEquals(1, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC)).getCount()); + assertEquals(1, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER_EXTRINSIC)).getCount()); + + ///////////////////////////// + // try to delete them both // + ///////////////////////////// + new DeleteAction().execute(new DeleteInput(TestUtils.TABLE_NAME_ORDER).withPrimaryKeys(List.of(OrderPreDeleteCustomizer.DELETE_WARN_ID, OrderPreDeleteCustomizer.DELETE_WARN_ID))); + + /////////////////////// + // count what's left // + /////////////////////// + assertEquals(1, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER)).getCount()); + assertEquals(1, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_LINE_ITEM)).getCount()); + assertEquals(1, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC)).getCount()); + assertEquals(0, new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER_EXTRINSIC)).getCount()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static class OrderPreDeleteCustomizer extends AbstractPreDeleteCustomizer + { + public static final Integer DELETE_ERROR_ID = 9999; + public static final Integer DELETE_WARN_ID = 9998; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) + { + for(QRecord record : records) + { + if(DELETE_ERROR_ID.equals(record.getValue("id"))) + { + record.addError(new BadInputStatusMessage("You may not delete this order")); + } + else if(DELETE_WARN_ID.equals(record.getValue("id"))) + { + record.addWarning(new QWarningMessage("It was bad that you deleted this order")); + } + } + + return (records); + } + } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/ReplaceActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/ReplaceActionTest.java new file mode 100644 index 00000000..4ce0e8ee --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/ReplaceActionTest.java @@ -0,0 +1,300 @@ +/* + * 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.tables; + + +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +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; +import com.kingsrook.qqq.backend.core.model.actions.tables.replace.ReplaceInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.replace.ReplaceOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; +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 ReplaceAction + *******************************************************************************/ +class ReplaceActionTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testWithoutFilter() throws QException + { + String tableName = TestUtils.TABLE_NAME_PERSON_MEMORY; + + /////////////////////////////// + // start with these 2 people // + /////////////////////////////// + new InsertAction().execute(new InsertInput(tableName).withRecords(List.of( + new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("noOfShoes", 1), + new QRecord().withValue("firstName", "Mr.").withValue("lastName", "Burns") + ))); + + assertEquals(1, countByFirstName("Homer")); + assertEquals(1, countByFirstName("Mr.")); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // now do a replace - updating one, inserting one, and (since it's not included in the list), deleting the other // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + List newPeople = List.of( + new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("noOfShoes", 2), + new QRecord().withValue("firstName", "Ned").withValue("lastName", "Flanders") + ); + + ReplaceInput replaceInput = new ReplaceInput(); + replaceInput.setTableName(tableName); + replaceInput.setKey(new UniqueKey("firstName", "lastName")); + replaceInput.setOmitDmlAudit(true); + replaceInput.setRecords(newPeople); + replaceInput.setFilter(null); + ReplaceOutput replaceOutput = new ReplaceAction().execute(replaceInput); + + assertEquals(1, replaceOutput.getInsertOutput().getRecords().size()); + assertEquals(1, replaceOutput.getUpdateOutput().getRecords().size()); + assertEquals(1, replaceOutput.getDeleteOutput().getDeletedRecordCount()); + + ////////////////////////////// + // assert homer was updated // + ////////////////////////////// + assertEquals(1, countByFirstName("Homer")); + assertEquals(2, getNoOfShoes("Homer", "Simpson")); + + /////////////////////////////////// + // assert Mr (burns) was deleted // + /////////////////////////////////// + assertEquals(0, countByFirstName("Mr.")); + + ///////////////////////////// + // assert ned was inserted // + ///////////////////////////// + assertEquals(1, countByFirstName("Ned")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOnlyInsertAndDelete() throws QException + { + String tableName = TestUtils.TABLE_NAME_PERSON_MEMORY; + + /////////////////////////////// + // start with these 2 people // + /////////////////////////////// + new InsertAction().execute(new InsertInput(tableName).withRecords(List.of( + new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("noOfShoes", 1), + new QRecord().withValue("firstName", "Marge").withValue("lastName", "Simpson").withValue("noOfShoes", 1) + ))); + + ////////////////////////////////////////// + // now do a replace that fully replaces // + ////////////////////////////////////////// + List newPeople = List.of( + new QRecord().withValue("firstName", "Ned").withValue("lastName", "Flanders"), + new QRecord().withValue("firstName", "Maude").withValue("lastName", "Flanders") + ); + + ReplaceInput replaceInput = new ReplaceInput(); + replaceInput.setTableName(tableName); + replaceInput.setKey(new UniqueKey("firstName", "lastName")); + replaceInput.setOmitDmlAudit(true); + replaceInput.setRecords(newPeople); + replaceInput.setFilter(null); + ReplaceOutput replaceOutput = new ReplaceAction().execute(replaceInput); + + assertEquals(2, replaceOutput.getInsertOutput().getRecords().size()); + assertEquals(0, replaceOutput.getUpdateOutput().getRecords().size()); + assertEquals(2, replaceOutput.getDeleteOutput().getDeletedRecordCount()); + + /////////////////////////////////////// + // assert homer & marge were deleted // + /////////////////////////////////////// + assertEquals(0, countByFirstName("Homer")); + assertEquals(0, countByFirstName("Marge")); + + ////////////////////////////////////// + // assert ned & maude were inserted // + ////////////////////////////////////// + assertEquals(1, countByFirstName("Ned")); + assertEquals(1, countByFirstName("Maude")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOnlyUpdates() throws QException + { + String tableName = TestUtils.TABLE_NAME_PERSON_MEMORY; + + /////////////////////////////// + // start with these 2 people // + /////////////////////////////// + new InsertAction().execute(new InsertInput(tableName).withRecords(List.of( + new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("noOfShoes", 1), + new QRecord().withValue("firstName", "Marge").withValue("lastName", "Simpson").withValue("noOfShoes", 1) + ))); + + ///////////////////////////////////////////// + // now do a replace that just updates them // + ///////////////////////////////////////////// + List newPeople = List.of( + new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("noOfShoes", 2), + new QRecord().withValue("firstName", "Marge").withValue("lastName", "Simpson").withValue("noOfShoes", 2) + ); + + ReplaceInput replaceInput = new ReplaceInput(); + replaceInput.setTableName(tableName); + replaceInput.setKey(new UniqueKey("firstName", "lastName")); + replaceInput.setOmitDmlAudit(true); + replaceInput.setRecords(newPeople); + replaceInput.setFilter(null); + ReplaceOutput replaceOutput = new ReplaceAction().execute(replaceInput); + + assertEquals(0, replaceOutput.getInsertOutput().getRecords().size()); + assertEquals(2, replaceOutput.getUpdateOutput().getRecords().size()); + assertEquals(0, replaceOutput.getDeleteOutput().getDeletedRecordCount()); + + /////////////////////////////////////// + // assert homer & marge were updated // + /////////////////////////////////////// + assertEquals(2, getNoOfShoes("Homer", "Simpson")); + assertEquals(2, getNoOfShoes("Marge", "Simpson")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testWithFilter() throws QException + { + String tableName = TestUtils.TABLE_NAME_PERSON_MEMORY; + + ///////////////////////////////////// + // start w/ 3 simpsons and a burns // + ///////////////////////////////////// + new InsertAction().execute(new InsertInput(tableName).withRecords(List.of( + new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("noOfShoes", 1), + new QRecord().withValue("firstName", "Marge").withValue("lastName", "Simpson").withValue("noOfShoes", 2), + new QRecord().withValue("firstName", "Bart").withValue("lastName", "Simpson").withValue("noOfShoes", 3), + new QRecord().withValue("firstName", "Mr.").withValue("lastName", "Burns") + ))); + + assertEquals(1, countByFirstName("Homer")); + assertEquals(1, countByFirstName("Marge")); + assertEquals(1, countByFirstName("Bart")); + assertEquals(1, countByFirstName("Mr.")); + + ///////////////////////////////////////////////////////////////////////////////// + // now - we'll replace the simpsons only - note the filter in the ReplaceInput // + // so even though Burns isn't in this list, he shouldn't be deleted. // + ///////////////////////////////////////////////////////////////////////////////// + List newPeople = List.of( + new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("noOfShoes", 4), + new QRecord().withValue("firstName", "Marge").withValue("lastName", "Simpson"), + new QRecord().withValue("firstName", "Lisa").withValue("lastName", "Simpson").withValue("noOfShoes", 5) + ); + + ReplaceInput replaceInput = new ReplaceInput(); + replaceInput.setTableName(tableName); + replaceInput.setKey(new UniqueKey("firstName", "lastName")); + replaceInput.setOmitDmlAudit(true); + replaceInput.setRecords(newPeople); + replaceInput.setFilter(new QQueryFilter(new QFilterCriteria("lastName", QCriteriaOperator.EQUALS, "Simpson"))); + ReplaceOutput replaceOutput = new ReplaceAction().execute(replaceInput); + + assertEquals(1, replaceOutput.getInsertOutput().getRecords().size()); + assertEquals(2, replaceOutput.getUpdateOutput().getRecords().size()); + assertEquals(1, replaceOutput.getDeleteOutput().getDeletedRecordCount()); + + ////////////////////////////// + // assert homer was updated // + ////////////////////////////// + assertEquals(1, countByFirstName("Homer")); + assertEquals(4, getNoOfShoes("Homer", "Simpson")); + + /////////////////////////////// + // assert Marge was no-op'ed // + /////////////////////////////// + assertEquals(1, countByFirstName("Marge")); + assertEquals(2, getNoOfShoes("Marge", "Simpson")); + + //////////////////////////////////// + // assert Mr (burns) was no-op'ed // + //////////////////////////////////// + assertEquals(1, countByFirstName("Mr.")); + assertNull(getNoOfShoes("Mr.", "Burns")); + + ///////////////////////////// + // assert Bart was deleted // + ///////////////////////////// + assertEquals(0, countByFirstName("Bart")); + + ////////////////////////////// + // assert Lisa was inserted // + ////////////////////////////// + assertEquals(1, countByFirstName("Lisa")); + assertEquals(5, getNoOfShoes("Lisa", "Simpson")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static Integer countByFirstName(String firstName) throws QException + { + return new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withFilter(new QQueryFilter(new QFilterCriteria("firstName", QCriteriaOperator.EQUALS, firstName)))).getCount(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static Integer getNoOfShoes(String firstName, String lastName) throws QException + { + return new GetAction().executeForRecord(new GetInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withUniqueKey(Map.of("firstName", firstName, "lastName", lastName))).getValueInteger("noOfShoes"); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/CollectionUtilsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/CollectionUtilsTest.java index 80425535..62c0573e 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/CollectionUtilsTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/CollectionUtilsTest.java @@ -34,6 +34,7 @@ import java.util.TreeMap; import java.util.function.Function; import com.google.gson.reflect.TypeToken; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -594,4 +595,26 @@ class CollectionUtilsTest extends BaseTest } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSwapMultiLevelMapKeys() + { + Map> input = MapBuilder.of( + "A", Map.of(1, "A1", 2, "A2", 3, "A3"), + "B", Map.of(1, "B1", 4, "B4"), + "C", null); + + Map> output = CollectionUtils.swapMultiLevelMapKeys(input); + + assertEquals(MapBuilder.of( + 1, Map.of("A", "A1", "B", "B1"), + 2, Map.of("A", "A2"), + 3, Map.of("A", "A3"), + 4, Map.of("B", "B4")), output); + } + } diff --git a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/APIRecordUtils.java b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/APIRecordUtils.java new file mode 100644 index 00000000..3f1d4ec9 --- /dev/null +++ b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/APIRecordUtils.java @@ -0,0 +1,136 @@ +/* + * 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.module.api.actions; + + +import java.io.Serializable; +import java.util.Map; +import com.kingsrook.qqq.backend.core.exceptions.QRuntimeException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import org.json.JSONArray; +import org.json.JSONObject; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class APIRecordUtils +{ + private static final QLogger LOG = QLogger.getLogger(APIRecordUtils.class); + + + + /******************************************************************************* + ** Take a QRecord whose field names are formatted in JSONQuery-style + ** (e.g., 'key' or 'key.subKey' or 'key[index].subKey') + ** and convert it to a JSONObject. + *******************************************************************************/ + public static JSONObject jsonQueryStyleQRecordToJSONObject(QTableMetaData table, QRecord record, boolean includeNonTableFields) + { + try + { + JSONObject body = new JSONObject(); + for(Map.Entry entry : record.getValues().entrySet()) + { + String fieldName = entry.getKey(); + Serializable value = entry.getValue(); + + if(fieldName.contains(".")) + { + JSONObject tmp = body; + String[] parts = fieldName.split("\\."); + for(int i = 0; i < parts.length - 1; i++) + { + String thisPart = parts[i]; + if(thisPart.contains("[")) + { + String arrayName = thisPart.replaceFirst("\\[.*", ""); + if(!tmp.has(arrayName)) + { + tmp.put(arrayName, new JSONArray()); + } + + JSONArray array = tmp.getJSONArray(arrayName); + Integer arrayIndex = Integer.parseInt(thisPart.replaceFirst(".*\\[", "").replaceFirst("].*", "")); + if(array.opt(arrayIndex) == null) + { + array.put(arrayIndex, new JSONObject()); + } + tmp = array.getJSONObject(arrayIndex); + } + else + { + if(!tmp.has(thisPart)) + { + tmp.put(thisPart, new JSONObject()); + } + tmp = tmp.getJSONObject(thisPart); + } + } + tmp.put(parts[parts.length - 1], value); + } + else + { + try + { + QFieldMetaData field = table.getField(fieldName); + body.put(getFieldBackendName(field), value); + } + catch(Exception e) + { + if(includeNonTableFields) + { + LOG.debug("Putting non-table-field in record", logPair("name", fieldName)); + body.put(fieldName, value); + } + } + } + } + return body; + } + catch(Exception e) + { + throw (new QRuntimeException("Error converting record to JSON Object", e)); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String getFieldBackendName(QFieldMetaData field) + { + String backendName = field.getBackendName(); + if(!StringUtils.hasContent(backendName)) + { + backendName = field.getName(); + } + return (backendName); + } + +} diff --git a/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/actions/APIRecordUtilsTest.java b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/actions/APIRecordUtilsTest.java new file mode 100644 index 00000000..90eb8e8f --- /dev/null +++ b/qqq-backend-module-api/src/test/java/com/kingsrook/qqq/backend/module/api/actions/APIRecordUtilsTest.java @@ -0,0 +1,79 @@ +/* + * 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.module.api.actions; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +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.module.api.BaseTest; +import com.kingsrook.qqq.backend.module.api.TestUtils; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + + +/******************************************************************************* + ** Unit test for APIRecordUtils + *******************************************************************************/ +class APIRecordUtilsTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() + { + QTableMetaData table = QContext.getQInstance().getTable(TestUtils.MOCK_TABLE_NAME); + table.withField(new QFieldMetaData("myField", QFieldType.INTEGER).withBackendName("myBackendName")); + + QRecord record = new QRecord() + .withValue("foo", 1) + .withValue("bar.baz", 2) + .withValue("list[0].a", 3) + .withValue("list[1].a", 4) + .withValue("list[1].b", 5) + .withValue("myField", 6); + + JSONObject jsonObject = APIRecordUtils.jsonQueryStyleQRecordToJSONObject(table, record, true); + assertEquals(1, jsonObject.getInt("foo")); + assertEquals(2, jsonObject.getJSONObject("bar").getInt("baz")); + assertEquals(3, jsonObject.getJSONArray("list").getJSONObject(0).getInt("a")); + assertEquals(4, jsonObject.getJSONArray("list").getJSONObject(1).getInt("a")); + assertEquals(5, jsonObject.getJSONArray("list").getJSONObject(1).getInt("b")); + assertEquals(6, jsonObject.getInt("myBackendName")); + assertFalse(jsonObject.has("myField")); + + /////////////////////////////////////////////////////// + // if we say "false" for includeNonTableFields, then // + // we should only get the myField field // + /////////////////////////////////////////////////////// + jsonObject = APIRecordUtils.jsonQueryStyleQRecordToJSONObject(table, record, false); + assertFalse(jsonObject.has("foo")); + assertEquals(6, jsonObject.getInt("myBackendName")); + } + +} \ No newline at end of file diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java index 85f0c602..beb902d5 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/QRecordApiAdapter.java @@ -35,6 +35,8 @@ import com.kingsrook.qqq.api.model.APIVersion; import com.kingsrook.qqq.api.model.APIVersionRange; import com.kingsrook.qqq.api.model.actions.ApiFieldCustomValueMapper; import com.kingsrook.qqq.api.model.actions.GetTableApiFieldsInput; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaData; +import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataContainer; import com.kingsrook.qqq.api.model.metadata.fields.ApiFieldMetaData; import com.kingsrook.qqq.api.model.metadata.fields.ApiFieldMetaDataContainer; import com.kingsrook.qqq.api.model.metadata.tables.ApiAssociationMetaData; @@ -289,7 +291,27 @@ public class QRecordApiAdapter if(!unrecognizedFieldNames.isEmpty()) { - throw (new QBadRequestException("Request body contained " + unrecognizedFieldNames.size() + " unrecognized field name" + StringUtils.plural(unrecognizedFieldNames) + ": " + StringUtils.joinWithCommasAndAnd(unrecognizedFieldNames))); + List otherVersionHints = new ArrayList<>(); + try + { + for(String unrecognizedFieldName : unrecognizedFieldNames) + { + String hint = lookForFieldInOtherVersions(unrecognizedFieldName, tableName, apiName, apiVersion); + if(hint != null) + { + otherVersionHints.add(hint); + } + } + } + catch(Exception e) + { + LOG.warn("Error looking for unrecognized field names in other api versions", e); + } + + throw (new QBadRequestException("Request body contained " + + (unrecognizedFieldNames.size() + " unrecognized field name" + StringUtils.plural(unrecognizedFieldNames) + ": " + StringUtils.joinWithCommasAndAnd(unrecognizedFieldNames) + ". ") + + (CollectionUtils.nullSafeIsEmpty(otherVersionHints) ? "" : StringUtils.join(" ", otherVersionHints)) + )); } return (qRecord); @@ -297,6 +319,37 @@ public class QRecordApiAdapter + /******************************************************************************* + ** + *******************************************************************************/ + private static String lookForFieldInOtherVersions(String unrecognizedFieldName, String tableName, String apiName, String apiVersion) throws QException + { + ApiInstanceMetaDataContainer apiInstanceMetaDataContainer = ApiInstanceMetaDataContainer.of(QContext.getQInstance()); + ApiInstanceMetaData apiInstanceMetaData = apiInstanceMetaDataContainer.getApiInstanceMetaData(apiName); + + List versionsWithThisField = new ArrayList<>(); + for(APIVersion supportedVersion : apiInstanceMetaData.getSupportedVersions()) + { + if(!supportedVersion.toString().equals(apiVersion)) + { + Map versionFields = getTableApiFieldMap(new ApiNameVersionAndTableName(apiName, supportedVersion.toString(), tableName)); + if(versionFields.containsKey(unrecognizedFieldName)) + { + versionsWithThisField.add(supportedVersion.toString()); + } + } + } + + if(CollectionUtils.nullSafeHasContents(versionsWithThisField)) + { + return (unrecognizedFieldName + " does not exist in version " + apiVersion + ", but does exist in versions: " + StringUtils.joinWithCommasAndAnd(versionsWithThisField) + ". "); + } + + return (null); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/QRecordApiAdapterTest.java b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/QRecordApiAdapterTest.java index 5494b9cb..24c6abc0 100644 --- a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/QRecordApiAdapterTest.java +++ b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/actions/QRecordApiAdapterTest.java @@ -158,7 +158,8 @@ class QRecordApiAdapterTest extends BaseTest {"firstName": "Tim", "noOfShoes": 2} """), TestUtils.TABLE_NAME_PERSON, TestUtils.API_NAME, TestUtils.V2022_Q4, true)) .isInstanceOf(QBadRequestException.class) - .hasMessageContaining("unrecognized field name: noOfShoes"); + .hasMessageContaining("unrecognized field name: noOfShoes") + .hasMessageContaining("noOfShoes does not exist in version 2022.Q4, but does exist in versions: 2023.Q1"); ///////////////////////////////////////////////////////////////////////// // current version doesn't have cost field - fail if you send it to us // @@ -167,7 +168,8 @@ class QRecordApiAdapterTest extends BaseTest {"firstName": "Tim", "cost": 2} """), TestUtils.TABLE_NAME_PERSON, TestUtils.API_NAME, TestUtils.V2023_Q1, true)) .isInstanceOf(QBadRequestException.class) - .hasMessageContaining("unrecognized field name: cost"); + .hasMessageContaining("unrecognized field name: cost") + .hasMessageNotContaining("cost does not exist in version 2023.Q1, but does exist in versions: 2023.Q2"); // this field only appears in a future version, not any current/supported versions. ///////////////////////////////// // excluded field always fails // @@ -178,7 +180,8 @@ class QRecordApiAdapterTest extends BaseTest {"firstName": "Tim", "price": 2} """), TestUtils.TABLE_NAME_PERSON, TestUtils.API_NAME, version, true)) .isInstanceOf(QBadRequestException.class) - .hasMessageContaining("unrecognized field name: price"); + .hasMessageContaining("unrecognized field name: price") + .hasMessageNotContaining("price does not exist in version"); // this field never appears, so no message about when it appears. } ////////////////////////////////////////////