diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java index 5014e0e0..e35f1a48 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java @@ -257,6 +257,14 @@ public class MemoryRecordStore for(QRecord record : input.getRecords()) { Serializable primaryKeyValue = ValueUtils.getValueAsFieldType(primaryKeyField.getType(), record.getValue(primaryKeyField.getName())); + + if(primaryKeyValue == null) + { + record.addError("Missing value in primary key field"); + outputRecords.add(record); + continue; + } + if(tableData.containsKey(primaryKeyValue)) { QRecord recordToUpdate = tableData.get(primaryKeyValue); diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateAction.java index ec174f2e..ee04e28f 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateAction.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateAction.java @@ -87,6 +87,7 @@ public class RDBMSUpdateAction extends AbstractRDBMSAction implements UpdateInte // record. So, we will first "hash" up the records by their list of fields being updated. // ///////////////////////////////////////////////////////////////////////////////////////////// ListingHash, QRecord> recordsByFieldBeingUpdated = new ListingHash<>(); + boolean haveAnyWithoutErorrs = false; for(QRecord record : updateInput.getRecords()) { //////////////////////////////////////////// @@ -103,6 +104,19 @@ public class RDBMSUpdateAction extends AbstractRDBMSAction implements UpdateInte .toList(); recordsByFieldBeingUpdated.add(updatableFields, record); + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // to update a record, we must have its primary key value - so - check - if it's missing, mark it as an error // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(record.getValue(table.getPrimaryKeyField()) == null) + { + record.addError("Missing value in primary key field"); + } + + if(CollectionUtils.nullSafeIsEmpty(record.getErrors())) + { + haveAnyWithoutErorrs = true; + } + ////////////////////////////////////////////////////////////////////////////// // go ahead and put the record into the output list at this point in time, // // so that the output list's order matches the input list order // @@ -113,6 +127,12 @@ public class RDBMSUpdateAction extends AbstractRDBMSAction implements UpdateInte outputRecords.add(outputRecord); } + if(!haveAnyWithoutErorrs) + { + LOG.info("Exiting early - all records have some error."); + return (rs); + } + try { Connection connection; @@ -192,6 +212,11 @@ public class RDBMSUpdateAction extends AbstractRDBMSAction implements UpdateInte List> values = new ArrayList<>(); for(QRecord record : recordList) { + if(CollectionUtils.nullSafeHasContents(record.getErrors())) + { + continue; + } + List rowValues = new ArrayList<>(); values.add(rowValues); @@ -204,6 +229,14 @@ public class RDBMSUpdateAction extends AbstractRDBMSAction implements UpdateInte rowValues.add(record.getValue(table.getPrimaryKeyField())); } + if(values.isEmpty()) + { + //////////////////////////////////////////////////////////////////////////////// + // if all records had errors, so we didn't push any values, then return early // + //////////////////////////////////////////////////////////////////////////////// + return; + } + Long mark = System.currentTimeMillis(); //////////////////////////////////////////////////////////////////////////////// @@ -241,6 +274,15 @@ public class RDBMSUpdateAction extends AbstractRDBMSAction implements UpdateInte { for(List page : CollectionUtils.getPages(recordList, QueryManager.PAGE_SIZE)) { + ////////////////////////////// + // skip records with errors // + ////////////////////////////// + page = page.stream().filter(r -> CollectionUtils.nullSafeIsEmpty(r.getErrors())).collect(Collectors.toList()); + if(page.isEmpty()) + { + continue; + } + String sql = writeUpdateSQLPrefix(table, fieldsBeingUpdated) + " IN (" + StringUtils.join(",", Collections.nCopies(page.size(), "?")) + ")"; // todo sql customization? - let each table have custom sql and/or param list @@ -293,6 +335,15 @@ public class RDBMSUpdateAction extends AbstractRDBMSAction implements UpdateInte for(int i = 1; i < recordList.size(); i++) { QRecord record = recordList.get(i); + + if(CollectionUtils.nullSafeHasContents(record.getErrors())) + { + /////////////////////////////////////////////////////// + // skip records w/ errors (that we won't be updating // + /////////////////////////////////////////////////////// + continue; + } + for(String fieldName : fieldsBeingUpdated) { if(!Objects.equals(record0.getValue(fieldName), record.getValue(fieldName))) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateActionTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateActionTest.java index f1733c7a..b1405646 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateActionTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSUpdateActionTest.java @@ -26,7 +26,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; 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; @@ -36,6 +39,7 @@ import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -98,7 +102,7 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest public void testUpdateOne() throws Exception { UpdateInput updateInput = initUpdateRequest(); - QRecord record = new QRecord().withTableName("person") + QRecord record = new QRecord() .withValue("id", 2) .withValue("firstName", "James") .withValue("lastName", "Kirk") @@ -141,18 +145,18 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest public void testUpdateManyWithDifferentColumnsAndValues() throws Exception { UpdateInput updateInput = initUpdateRequest(); - QRecord record1 = new QRecord().withTableName("person") + QRecord record1 = new QRecord() .withValue("id", 1) .withValue("firstName", "Darren") .withValue("lastName", "From Bewitched") .withValue("birthDate", "1900-01-01"); - QRecord record2 = new QRecord().withTableName("person") + QRecord record2 = new QRecord() .withValue("id", 3) .withValue("firstName", "Wilt") .withValue("birthDate", null); - QRecord record3 = new QRecord().withTableName("person") + QRecord record3 = new QRecord() .withValue("id", 5) .withValue("firstName", "Richard") .withValue("birthDate", null); @@ -216,13 +220,13 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest public void testUpdateManyWithSameColumnsDifferentValues() throws Exception { UpdateInput updateInput = initUpdateRequest(); - QRecord record1 = new QRecord().withTableName("person") + QRecord record1 = new QRecord() .withValue("id", 1) .withValue("firstName", "Darren") .withValue("lastName", "From Bewitched") .withValue("birthDate", "1900-01-01"); - QRecord record2 = new QRecord().withTableName("person") + QRecord record2 = new QRecord() .withValue("id", 3) .withValue("firstName", "Wilt") .withValue("lastName", "Tim's Uncle") @@ -276,7 +280,7 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest List records = new ArrayList<>(); for(int i = 1; i <= 5; i++) { - records.add(new QRecord().withTableName("person") + records.add(new QRecord() .withValue("id", i) .withValue("birthDate", "1999-09-09")); } @@ -312,7 +316,7 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest UpdateInput updateInput = initUpdateRequest(); List records = new ArrayList<>(); - records.add(new QRecord().withTableName("person") + records.add(new QRecord() .withValue("id", 1) .withValue("firstName", "Johnny Updated")); updateInput.setRecords(records); @@ -336,7 +340,7 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest { UpdateInput updateInput = initUpdateRequest(); List records = new ArrayList<>(); - records.add(new QRecord().withTableName("person") + records.add(new QRecord() .withValue("id", 1) .withValue("createDate", "2022-10-03T10:29:35Z") .withValue("firstName", "Johnny Updated")); @@ -346,6 +350,63 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest + /******************************************************************************* + ** Make sure that records without a primary key come back with error. + *******************************************************************************/ + @Test + void testWithoutPrimaryKeyErrors() throws Exception + { + { + UpdateInput updateInput = initUpdateRequest(); + List records = new ArrayList<>(); + records.add(new QRecord() + .withValue("firstName", "Johnny Updated")); + updateInput.setRecords(records); + UpdateOutput updateOutput = new RDBMSUpdateAction().execute(updateInput); + assertFalse(updateOutput.getRecords().get(0).getErrors().isEmpty()); + assertEquals("Missing value in primary key field", updateOutput.getRecords().get(0).getErrors().get(0)); + } + + { + UpdateInput updateInput = initUpdateRequest(); + List records = new ArrayList<>(); + records.add(new QRecord() + .withValue("id", null) + .withValue("firstName", "Johnny Updated")); + updateInput.setRecords(records); + UpdateOutput updateOutput = new RDBMSUpdateAction().execute(updateInput); + assertFalse(updateOutput.getRecords().get(0).getErrors().isEmpty()); + assertEquals("Missing value in primary key field", updateOutput.getRecords().get(0).getErrors().get(0)); + } + + { + UpdateInput updateInput = initUpdateRequest(); + List records = new ArrayList<>(); + records.add(new QRecord() + .withValue("id", null) + .withValue("firstName", "Johnny Not Updated")); + records.add(new QRecord() + .withValue("id", 2) + .withValue("firstName", "Johnny Updated")); + updateInput.setRecords(records); + UpdateOutput updateOutput = new RDBMSUpdateAction().execute(updateInput); + + assertFalse(updateOutput.getRecords().get(0).getErrors().isEmpty()); + assertEquals("Missing value in primary key field", updateOutput.getRecords().get(0).getErrors().get(0)); + + assertTrue(updateOutput.getRecords().get(1).getErrors().isEmpty()); + + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON); + getInput.setPrimaryKey(2); + GetOutput getOutput = new GetAction().execute(getInput); + assertEquals("Johnny Updated", getOutput.getRecord().getValueString("firstName")); + } + + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -369,7 +430,7 @@ public class RDBMSUpdateActionTest extends RDBMSActionTest private UpdateInput initUpdateRequest() { UpdateInput updateInput = new UpdateInput(); - updateInput.setTableName(TestUtils.defineTablePerson().getName()); + updateInput.setTableName(TestUtils.TABLE_NAME_PERSON); return updateInput; } diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java index d605eda8..0fab9400 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java @@ -91,6 +91,7 @@ import org.apache.commons.lang.BooleanUtils; import org.eclipse.jetty.http.HttpStatus; import org.json.JSONArray; import org.json.JSONObject; +import org.json.JSONTokener; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; import static com.kingsrook.qqq.backend.javalin.QJavalinImplementation.SLOW_LOG_THRESHOLD_MS; @@ -137,13 +138,16 @@ public class QJavalinApiHandler ApiBuilder.get("/query", QJavalinApiHandler::doQuery); // ApiBuilder.post("/query", QJavalinApiHandler::doQuery); + ApiBuilder.post("/bulk", QJavalinApiHandler::bulkInsert); + ApiBuilder.patch("/bulk", QJavalinApiHandler::bulkUpdate); + ApiBuilder.delete("/bulk", QJavalinApiHandler::bulkDelete); + + ////////////////////////////////////////////////////////////////// + // remember to keep the wildcard paths after the specific paths // + ////////////////////////////////////////////////////////////////// ApiBuilder.get("/{primaryKey}", QJavalinApiHandler::doGet); ApiBuilder.patch("/{primaryKey}", QJavalinApiHandler::doUpdate); ApiBuilder.delete("/{primaryKey}", QJavalinApiHandler::doDelete); - - ApiBuilder.post("/bulk", QJavalinApiHandler::bulkInsert); - // patch("/bulk", QJavalinApiHandler::bulkUpdate); - // delete("/bulk", QJavalinApiHandler::bulkDelete); }); }); @@ -722,8 +726,15 @@ public class QJavalinApiHandler throw (new QBadRequestException("Missing required POST body")); } - JSONObject jsonObject = new JSONObject(context.body()); + JSONTokener jsonTokener = new JSONTokener(context.body().trim()); + JSONObject jsonObject = new JSONObject(jsonTokener); + insertInput.setRecords(List.of(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, version))); + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON object.")); + } } catch(QBadRequestException qbre) { @@ -787,13 +798,21 @@ public class QJavalinApiHandler ArrayList recordList = new ArrayList<>(); insertInput.setRecords(recordList); - JSONArray jsonArray = new JSONArray(context.body()); + + JSONTokener jsonTokener = new JSONTokener(context.body().trim()); + JSONArray jsonArray = new JSONArray(jsonTokener); + for(int i = 0; i < jsonArray.length(); i++) { JSONObject jsonObject = jsonArray.getJSONObject(i); recordList.add(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, version)); } + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON array.")); + } + if(recordList.isEmpty()) { throw (new QBadRequestException("No records were found in the POST body")); @@ -851,6 +870,229 @@ public class QJavalinApiHandler + /******************************************************************************* + ** + *******************************************************************************/ + private static void bulkUpdate(Context context) + { + String version = context.pathParam("version"); + String tableApiName = context.pathParam("tableName"); + + try + { + QTableMetaData table = validateTableAndVersion(context, version, tableApiName); + String tableName = table.getName(); + + UpdateInput updateInput = new UpdateInput(); + + setupSession(context, updateInput); + QJavalinAccessLogger.logStart("bulkUpdate", logPair("table", tableName)); + + updateInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(updateInput, TablePermissionSubType.EDIT); + + ///////////////// + // build input // + ///////////////// + try + { + if(!StringUtils.hasContent(context.body())) + { + throw (new QBadRequestException("Missing required PATCH body")); + } + + ArrayList recordList = new ArrayList<>(); + updateInput.setRecords(recordList); + + JSONTokener jsonTokener = new JSONTokener(context.body().trim()); + JSONArray jsonArray = new JSONArray(jsonTokener); + + for(int i = 0; i < jsonArray.length(); i++) + { + JSONObject jsonObject = jsonArray.getJSONObject(i); + recordList.add(QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, version)); + } + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON array.")); + } + + if(recordList.isEmpty()) + { + throw (new QBadRequestException("No records were found in the PATCH body")); + } + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); + } + + ////////////// + // execute! // + ////////////// + UpdateAction updateAction = new UpdateAction(); + UpdateOutput updateOutput = updateAction.execute(updateInput); + + /////////////////////////////////////// + // process records to build response // + /////////////////////////////////////// + List> response = new ArrayList<>(); + for(QRecord record : updateOutput.getRecords()) + { + LinkedHashMap outputRecord = new LinkedHashMap<>(); + response.add(outputRecord); + + List errors = record.getErrors(); + if(CollectionUtils.nullSafeHasContents(errors)) + { + outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); + outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); + outputRecord.put("error", "Error updating " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors)); + } + else + { + outputRecord.put("statusCode", HttpStatus.Code.NO_CONTENT.getCode()); + outputRecord.put("statusText", HttpStatus.Code.NO_CONTENT.getMessage()); + } + } + + QJavalinAccessLogger.logEndSuccess(); + context.status(HttpStatus.Code.MULTI_STATUS.getCode()); + context.result(JsonUtils.toJson(response)); + } + catch(Exception e) + { + QJavalinAccessLogger.logEndFail(e); + handleException(context, e); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void bulkDelete(Context context) + { + String version = context.pathParam("version"); + String tableApiName = context.pathParam("tableName"); + + try + { + QTableMetaData table = validateTableAndVersion(context, version, tableApiName); + String tableName = table.getName(); + + DeleteInput deleteInput = new DeleteInput(); + + setupSession(context, deleteInput); + QJavalinAccessLogger.logStart("bulkDelete", logPair("table", tableName)); + + deleteInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(deleteInput, TablePermissionSubType.DELETE); + + ///////////////// + // build input // + ///////////////// + try + { + if(!StringUtils.hasContent(context.body())) + { + throw (new QBadRequestException("Missing required DELETE body")); + } + + ArrayList primaryKeyList = new ArrayList<>(); + deleteInput.setPrimaryKeys(primaryKeyList); + + JSONTokener jsonTokener = new JSONTokener(context.body().trim()); + JSONArray jsonArray = new JSONArray(jsonTokener); + + for(int i = 0; i < jsonArray.length(); i++) + { + Object object = jsonArray.get(i); + if(object instanceof JSONArray || object instanceof JSONObject) + { + throw (new QBadRequestException("One or more elements inside the DELETE body JSONArray was not a primitive value")); + } + primaryKeyList.add(String.valueOf(object)); + } + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON array.")); + } + + if(primaryKeyList.isEmpty()) + { + throw (new QBadRequestException("No primary keys were found in the DELETE body")); + } + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON array: " + e.getMessage(), e)); + } + + ////////////// + // execute! // + ////////////// + DeleteAction deleteAction = new DeleteAction(); + DeleteOutput deleteOutput = deleteAction.execute(deleteInput); + + /////////////////////////////////////// + // process records to build response // + /////////////////////////////////////// + List> response = new ArrayList<>(); + + List recordsWithErrors = deleteOutput.getRecordsWithErrors(); + Map primaryKeyToErrorMap = new HashMap<>(); + for(QRecord recordWithError : CollectionUtils.nonNullList(recordsWithErrors)) + { + String primaryKey = recordWithError.getValueString(table.getPrimaryKeyField()); + primaryKeyToErrorMap.put(primaryKey, StringUtils.join(", ", recordWithError.getErrors())); + } + + for(Serializable primaryKey : deleteInput.getPrimaryKeys()) + { + LinkedHashMap outputRecord = new LinkedHashMap<>(); + response.add(outputRecord); + + String primaryKeyString = ValueUtils.getValueAsString((primaryKey)); + if(primaryKeyToErrorMap.containsKey(primaryKeyString)) + { + outputRecord.put("statusCode", HttpStatus.Code.BAD_REQUEST.getCode()); + outputRecord.put("statusText", HttpStatus.Code.BAD_REQUEST.getMessage()); + outputRecord.put("error", "Error deleting " + table.getLabel() + ": " + primaryKeyToErrorMap.get(primaryKeyString)); + } + else + { + outputRecord.put("statusCode", HttpStatus.Code.NO_CONTENT.getCode()); + outputRecord.put("statusText", HttpStatus.Code.NO_CONTENT.getMessage()); + } + } + + QJavalinAccessLogger.logEndSuccess(); + context.status(HttpStatus.Code.MULTI_STATUS.getCode()); + context.result(JsonUtils.toJson(response)); + } + catch(Exception e) + { + QJavalinAccessLogger.logEndFail(e); + handleException(context, e); + } + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -892,13 +1134,20 @@ public class QJavalinApiHandler { if(!StringUtils.hasContent(context.body())) { - throw (new QBadRequestException("Missing required POST body")); + throw (new QBadRequestException("Missing required PATCH body")); } - JSONObject jsonObject = new JSONObject(context.body()); - QRecord qRecord = QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, version); + JSONTokener jsonTokener = new JSONTokener(context.body().trim()); + JSONObject jsonObject = new JSONObject(jsonTokener); + + QRecord qRecord = QRecordApiAdapter.apiJsonObjectToQRecord(jsonObject, tableName, version); qRecord.setValue(table.getPrimaryKeyField(), primaryKey); updateInput.setRecords(List.of(qRecord)); + + if(jsonTokener.more()) + { + throw (new QBadRequestException("Body contained more than a single JSON object.")); + } } catch(QBadRequestException qbre) { diff --git a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java index 9d0e9c12..f95ec8b2 100644 --- a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java +++ b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java @@ -29,11 +29,17 @@ import com.kingsrook.qqq.api.TestUtils; import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData; 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.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; 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.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.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.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; @@ -442,17 +448,16 @@ class QJavalinApiHandlerTest extends BaseTest QRecord personRecord = getPersonRecord(1); assertNull(personRecord); - /////////////////////////////////////////////////////////////////////////////////////////// - // apparently, as long as the body *starts with* json, the JSONObject constructor builds // - // a json object out of it?? so... this in this case we expected 400, but get 201... // - /////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////// + // If more than just a json object, fail // + /////////////////////////////////////////// response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") .body(""" {"firstName": "Moe"} Not json """) .asString(); - assertErrorResponse(HttpStatus.CREATED_201, null, response); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body contained more than a single JSON object", response); } @@ -540,17 +545,16 @@ class QJavalinApiHandlerTest extends BaseTest QRecord personRecord = getPersonRecord(1); assertNull(personRecord); - /////////////////////////////////////////////////////////////////////////////////////////// - // apparently, as long as the body *starts with* json, the JSONObject constructor builds // - // a json object out of it?? so... this in this case we expected 400, but get 201... // - /////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////// + // If more than just a json array, fail // + ////////////////////////////////////////// response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/bulk") .body(""" [{"firstName": "Moe"}] Not json """) .asString(); - assertErrorResponse(HttpStatus.MULTI_STATUS_207, null, response); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body contained more than a single JSON array", response); } @@ -618,7 +622,7 @@ class QJavalinApiHandlerTest extends BaseTest response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") // no body .asString(); - assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Missing required POST body", response); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Missing required PATCH body", response); response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") .body(""" @@ -647,17 +651,185 @@ class QJavalinApiHandlerTest extends BaseTest QRecord personRecord = getPersonRecord(1); assertEquals("Mo", personRecord.getValueString("firstName")); - /////////////////////////////////////////////////////////////////////////////////////////// - // apparently, as long as the body *starts with* json, the JSONObject constructor builds // - // a json object out of it?? so... this in this case we expected 400, but get 204... // - /////////////////////////////////////////////////////////////////////////////////////////// response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") .body(""" {"firstName": "Moe"} Not json """) .asString(); - assertErrorResponse(HttpStatus.NO_CONTENT_204, null, response); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body contained more than a single JSON object", response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkUpdate207() throws QException + { + insertSimpsons(); + + HttpResponse response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + [ + {"id": 1, "email": "homer@simpson.com"}, + {"id": 2, "email": "marge@simpson.com"}, + {"email": "nobody@simpson.com"} + ] + """) + .asString(); + assertEquals(HttpStatus.MULTI_STATUS_207, response.getStatus()); + JSONArray jsonArray = new JSONArray(response.getBody()); + assertEquals(3, jsonArray.length()); + + assertEquals(HttpStatus.NO_CONTENT_204, jsonArray.getJSONObject(0).getInt("statusCode")); + assertEquals(HttpStatus.NO_CONTENT_204, jsonArray.getJSONObject(1).getInt("statusCode")); + + assertEquals(HttpStatus.BAD_REQUEST_400, jsonArray.getJSONObject(2).getInt("statusCode")); + assertEquals("Error updating Person: Missing value in primary key field", jsonArray.getJSONObject(2).getString("error")); + + QRecord record = getPersonRecord(1); + assertEquals("homer@simpson.com", record.getValueString("email")); + + record = getPersonRecord(2); + assertEquals("marge@simpson.com", record.getValueString("email")); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_PERSON); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.EQUALS, "nobody@simpson.com"))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(0, queryOutput.getRecords().size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkUpdate400s() throws QException + { + HttpResponse response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + {"firstName": "Moe"} + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON array: A JSONArray text must start with '['", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/bulk") + // no body + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Missing required PATCH body", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body("[]") + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "No records were found in the PATCH body", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + [{"firstName": "Moe", "foo": "bar"}] + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Request body contained 1 unrecognized field name: foo", response); + + //////////////////////////////// + // assert nothing got updated // + //////////////////////////////// + QRecord personRecord = getPersonRecord(1); + assertNull(personRecord); + + ////////////////////////////////////////// + // If more than just a json array, fail // + ////////////////////////////////////////// + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + [{"firstName": "Moe"}] + Not json + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body contained more than a single JSON array", response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkDelete207() throws QException + { + insertSimpsons(); + + HttpResponse response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + [ 1, 3, 5 ] + """) + .asString(); + assertEquals(HttpStatus.MULTI_STATUS_207, response.getStatus()); + JSONArray jsonArray = new JSONArray(response.getBody()); + assertEquals(3, jsonArray.length()); + + assertEquals(HttpStatus.NO_CONTENT_204, jsonArray.getJSONObject(0).getInt("statusCode")); + assertEquals(HttpStatus.NO_CONTENT_204, jsonArray.getJSONObject(1).getInt("statusCode")); + assertEquals(HttpStatus.NO_CONTENT_204, jsonArray.getJSONObject(2).getInt("statusCode")); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_PERSON); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(2, queryOutput.getRecords().size()); + assertEquals(List.of(2, 4), queryOutput.getRecords().stream().map(r -> r.getValueInteger("id")).toList()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testBulkDelete400s() throws QException + { + HttpResponse response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + 1, 2, 3 + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON array: A JSONArray text must start with '['", response); + + response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/bulk") + // no body + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Missing required DELETE body", response); + + response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body("[]") + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "No primary keys were found in the DELETE body", response); + + response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + [{"id": 1}] + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "One or more elements inside the DELETE body JSONArray was not a primitive value", response); + + //////////////////////////////// + // assert nothing got deleted // + //////////////////////////////// + QRecord personRecord = getPersonRecord(1); + assertNull(personRecord); + + ////////////////////////////////////////// + // If more than just a json array, fail // + ////////////////////////////////////////// + response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/bulk") + .body(""" + [1,2,3] + Not json + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body contained more than a single JSON array", response); }