From b0aaf61e998928db248bb90218c0288a2fa86610 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 4 Mar 2024 07:52:47 -0600 Subject: [PATCH] CE-940 Add AuditDetailAccumulator, and a means to share it (generically) via QContext --- .../qqq/backend/core/context/QContext.java | 94 +++++++++++ .../audits/AuditDetailAccumulator.java | 156 ++++++++++++++++++ .../actions/audits/AuditSingleInput.java | 2 +- .../audits/AuditDetailAccumulatorTest.java | 81 +++++++++ 4 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulator.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulatorTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java index 80515a5e..69cb0dbd 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java @@ -22,6 +22,9 @@ package com.kingsrook.qqq.backend.core.context; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import java.util.Stack; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; @@ -31,6 +34,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.session.QSession; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -47,6 +51,7 @@ public class QContext private static ThreadLocal qBackendTransactionThreadLocal = new ThreadLocal<>(); private static ThreadLocal> actionStackThreadLocal = new ThreadLocal<>(); + private static ThreadLocal> objectsThreadLocal = new ThreadLocal<>(); /******************************************************************************* @@ -132,6 +137,7 @@ public class QContext qSessionThreadLocal.remove(); qBackendTransactionThreadLocal.remove(); actionStackThreadLocal.remove(); + objectsThreadLocal.remove(); } @@ -259,4 +265,92 @@ public class QContext return (Optional.of(actionStackThreadLocal.get().get(0))); } + + + /******************************************************************************* + ** get one named object from the Context for the current thread. may return null. + *******************************************************************************/ + public static Serializable getObject(String key) + { + if(objectsThreadLocal.get() == null) + { + return null; + } + return objectsThreadLocal.get().get(key); + } + + + /******************************************************************************* + ** get one named object from the Context for the current thread, cast to the + ** specified type if possible. if not found, or wrong type, empty is returned. + *******************************************************************************/ + public static Optional getObject(String key, Class type) + { + Serializable object = getObject(key); + + if(type.isInstance(object)) + { + return Optional.of(type.cast(object)); + } + else if(object == null) + { + return Optional.empty(); + } + else + { + LOG.warn("Unexpected type of object found in session under key [" + key + "]", + logPair("expectedType", type.getName()), + logPair("actualType", object.getClass().getName()) + ); + return Optional.empty(); + } + } + + + + /******************************************************************************* + ** put a named object into the Context for the current thread. + *******************************************************************************/ + public static void setObject(String key, Serializable object) + { + if(objectsThreadLocal.get() == null) + { + objectsThreadLocal.set(new HashMap<>()); + } + objectsThreadLocal.get().put(key, object); + } + + + + /******************************************************************************* + ** remove a named object from the Context of the current thread. + *******************************************************************************/ + public static void removeObject(String key) + { + if(objectsThreadLocal.get() != null) + { + objectsThreadLocal.get().remove(key); + } + } + + + + /******************************************************************************* + ** get the full map of named objects for the current thread (possibly null). + *******************************************************************************/ + public static Map getObjects() + { + return objectsThreadLocal.get(); + } + + + + /******************************************************************************* + ** fully replace the map of named objets for the current thread. + *******************************************************************************/ + public static void setObjects(Map objects) + { + objectsThreadLocal.set(objects); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulator.java new file mode 100644 index 00000000..bf653d41 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulator.java @@ -0,0 +1,156 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.audits; + + +import java.io.Serializable; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Object to accumulate multiple audit-details to be recorded under a single + ** audit per-record, within a process step. Especially useful if/when the + ** process step spreads its work out through multiple classes. + ** + ** Pattern of usage looks like: + ** + **
+ ** // declare as a field (or local) w/ message for the audit headers
+ ** private AuditDetailAccumulator auditDetailAccumulator = new AuditDetailAccumulator("Audit header message");
+ **
+ **  // put into thread context
+ **  AuditDetailAccumulator.setInContext(auditDetailAccumulator);
+ **
+ **  // add a detail message for a record
+ **  auditDetailAccumulator.addAuditDetail(tableName, record, "Detail message");
+ **
+ **  // in another class, get the accumulator from context and safely add a detail message
+ **  AuditDetailAccumulator.getFromContext().ifPresent(ada -> ada.addAuditDetail(tableName, record, "More Details"));
+ **
+ **  // at the end of a step run/runOnePage method, add the accumulated audit details to step output
+ **  auditDetailAccumulator.getAccumulatedAuditSingleInputs().forEach(runBackendStepOutput::addAuditSingleInput);
+ **  auditDetailAccumulator.clear();
+ ** 
+ *******************************************************************************/ +public class AuditDetailAccumulator implements Serializable +{ + private static final QLogger LOG = QLogger.getLogger(AuditDetailAccumulator.class); + + private static final String objectKey = AuditDetailAccumulator.class.getSimpleName(); + + private String header; + + private Map recordAuditInputMap = new HashMap<>(); + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public AuditDetailAccumulator(String header) + { + this.header = header; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void setInContext() + { + QContext.setObject(objectKey, this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static Optional getFromContext() + { + return QContext.getObject(objectKey, AuditDetailAccumulator.class); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void addAuditDetail(String tableName, QRecordEntity entity, String message) + { + if(entity != null) + { + addAuditDetail(tableName, entity.toQRecord(), message); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void addAuditDetail(String tableName, QRecord record, String message) + { + QTableMetaData table = QContext.getQInstance().getTable(tableName); + Serializable primaryKey = record.getValue(table.getPrimaryKeyField()); + if(primaryKey == null) + { + LOG.info("Missing primary key in input record - audit detail message will not be recorded.", logPair("message", message)); + return; + } + + AuditSingleInput auditSingleInput = recordAuditInputMap.computeIfAbsent(new TableNameAndPrimaryKey(tableName, primaryKey), (key) -> new AuditSingleInput(table, record, header)); + auditSingleInput.addDetail(message); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public Collection getAccumulatedAuditSingleInputs() + { + return (recordAuditInputMap.values()); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clear() + { + recordAuditInputMap.clear(); + } + + + private record TableNameAndPrimaryKey(String tableName, Serializable primaryKey) {} +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java index 0cbf6a65..83b8785c 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java @@ -41,7 +41,7 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils; /******************************************************************************* ** Input data to insert a single audit record (with optional child record).. *******************************************************************************/ -public class AuditSingleInput +public class AuditSingleInput implements Serializable { private String auditTableName; private String auditUserName; diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulatorTest.java new file mode 100644 index 00000000..ed360ba8 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditDetailAccumulatorTest.java @@ -0,0 +1,81 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.audits; + + +import java.util.Collection; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for AuditDetailAccumulator + *******************************************************************************/ +class AuditDetailAccumulatorTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() + { + AuditDetailAccumulator auditDetailAccumulator = new AuditDetailAccumulator("During test"); + auditDetailAccumulator.addAuditDetail(TestUtils.TABLE_NAME_PERSON, new QRecord().withValue("id", 1701), "Something happened"); + auditDetailAccumulator.addAuditDetail(TestUtils.TABLE_NAME_PERSON, new QRecord().withValue("id", 1701), "Something else happened"); + auditDetailAccumulator.addAuditDetail(TestUtils.TABLE_NAME_PERSON, new QRecord().withValue("id", 74256), "Something happened here too"); + auditDetailAccumulator.addAuditDetail(TestUtils.TABLE_NAME_ORDER, new QRecord().withValue("id", 74256), "Something happened to an order"); + + Collection auditSingleInputs = auditDetailAccumulator.getAccumulatedAuditSingleInputs(); + assertEquals(3, auditSingleInputs.size()); + assertThat(auditSingleInputs).anyMatch(asi -> asi.getAuditTableName().equals(TestUtils.TABLE_NAME_PERSON) && asi.getRecordId().equals(1701) && asi.getDetails().size() == 2); + assertThat(auditSingleInputs).anyMatch(asi -> asi.getAuditTableName().equals(TestUtils.TABLE_NAME_PERSON) && asi.getRecordId().equals(74256) && asi.getDetails().size() == 1); + assertThat(auditSingleInputs).anyMatch(asi -> asi.getAuditTableName().equals(TestUtils.TABLE_NAME_ORDER) && asi.getRecordId().equals(74256) && asi.getDetails().size() == 1); + + auditDetailAccumulator.clear();; + auditSingleInputs = auditDetailAccumulator.getAccumulatedAuditSingleInputs(); + assertEquals(0, auditSingleInputs.size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testContext() + { + AuditDetailAccumulator auditDetailAccumulator = new AuditDetailAccumulator("During test"); + auditDetailAccumulator.setInContext(); + + AuditDetailAccumulator.getFromContext().ifPresent(ada -> ada.addAuditDetail(TestUtils.TABLE_NAME_PERSON, new QRecord().withValue("id", 1701), "Something happened")); + + Collection auditSingleInputs = auditDetailAccumulator.getAccumulatedAuditSingleInputs(); + assertEquals(1, auditSingleInputs.size()); + assertThat(auditSingleInputs).anyMatch(asi -> asi.getAuditTableName().equals(TestUtils.TABLE_NAME_PERSON) && asi.getRecordId().equals(1701) && asi.getDetails().size() == 1); + } + +} \ No newline at end of file