diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/ReportsFullInstanceVerifier.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/ReportsFullInstanceVerifier.java
new file mode 100644
index 00000000..60c2f509
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/ReportsFullInstanceVerifier.java
@@ -0,0 +1,258 @@
+/*
+ * 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.processes.implementations.savedreports;
+
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
+import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat;
+import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
+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.QQueryFilter;
+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.Capability;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.savedreports.RenderedReport;
+import com.kingsrook.qqq.backend.core.model.savedreports.ReportColumns;
+import com.kingsrook.qqq.backend.core.model.savedreports.SavedReport;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.JsonUtils;
+import com.kingsrook.qqq.backend.core.utils.Pair;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** Utility for verifying that the RenderReports process works for all fields,
+ ** on all tables, and all exposed joins.
+ **
+ ** Meant for use within a unit test, or maybe as part of an instance's boot-up/
+ ** validation.
+ *******************************************************************************/
+public class ReportsFullInstanceVerifier
+{
+ private static final QLogger LOG = QLogger.getLogger(ReportsFullInstanceVerifier.class);
+
+ private boolean removeRenderedReports = true;
+ private boolean filterForAtMostOneRowPerReport = true;
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void verify(Collection tables, String storageTableName) throws QException
+ {
+ Map, Exception> caughtExceptions = new LinkedHashMap<>();
+ for(QTableMetaData table : tables)
+ {
+ if(table.isCapabilityEnabled(QContext.getQInstance().getBackendForTable(table.getName()), Capability.TABLE_QUERY))
+ {
+ LOG.info("Verifying Reports on table", logPair("tableName", table.getName()));
+
+ //////////////////////////////////////////////
+ // run the table by itself (no join fields) //
+ //////////////////////////////////////////////
+ runReport(table.getName(), Collections.emptyList(), "main-table-only", caughtExceptions, storageTableName);
+
+ ///////////////////////////////////////////////////
+ // run once w/ the fields from each exposed join //
+ ///////////////////////////////////////////////////
+ for(ExposedJoin exposedJoin : CollectionUtils.nonNullList(table.getExposedJoins()))
+ {
+ runReport(table.getName(), List.of(exposedJoin), "join-" + exposedJoin.getLabel(), caughtExceptions, storageTableName);
+ }
+
+ /////////////////////////////////////////////////
+ // run w/ all exposed joins (if there are any) //
+ /////////////////////////////////////////////////
+ if(CollectionUtils.nullSafeHasContents(table.getExposedJoins()))
+ {
+ runReport(table.getName(), table.getExposedJoins(), "all-joins", caughtExceptions, storageTableName);
+ }
+ }
+ }
+
+ //////////////////////////////////
+ // log out an exceptions caught //
+ //////////////////////////////////
+ if(!caughtExceptions.isEmpty())
+ {
+ for(Map.Entry, Exception> entry : caughtExceptions.entrySet())
+ {
+ LOG.info("Caught an exception verifying reports", entry.getValue(), logPair("tableName", entry.getKey().getA()), logPair("fieldName", entry.getKey().getB()));
+ }
+ throw (new QException("Reports Verification failed with " + caughtExceptions.size() + " exception" + StringUtils.plural(caughtExceptions.size())));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private void runReport(String tableName, List exposedJoinList, String description, Map, Exception> caughtExceptions, String storageTableName)
+ {
+ try
+ {
+ ////////////////////////////////////////////////////////////////////////////////////////////////
+ // build the list of reports to include in the column - starting with all fields in the table //
+ ////////////////////////////////////////////////////////////////////////////////////////////////
+ ReportColumns reportColumns = new ReportColumns();
+ for(QFieldMetaData field : QContext.getQInstance().getTable(tableName).getFields().values())
+ {
+ reportColumns.withColumn(field.getName());
+ }
+
+ ///////////////////////////////////////////////////
+ // add all fields from all exposed joins as well //
+ ///////////////////////////////////////////////////
+ for(ExposedJoin exposedJoin : CollectionUtils.nonNullList(exposedJoinList))
+ {
+ QTableMetaData joinTable = QContext.getQInstance().getTable(exposedJoin.getJoinTable());
+ for(QFieldMetaData field : joinTable.getFields().values())
+ {
+ reportColumns.withColumn(joinTable.getName() + "." + field.getName());
+ }
+ }
+
+ QQueryFilter queryFilter = new QQueryFilter();
+
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // if caller is okay with a filter that should limit the report to a small number of rows (could be more than 1 for to-many joins), then do so //
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ if(filterForAtMostOneRowPerReport)
+ {
+ queryFilter.withCriteria(QContext.getQInstance().getTable(tableName).getPrimaryKeyField(), QCriteriaOperator.EQUALS, 1);
+ }
+
+ //////////////////////////////////
+ // insert a saved report record //
+ //////////////////////////////////
+ SavedReport savedReport = new SavedReport();
+ savedReport.setTableName(tableName);
+ savedReport.setLabel("Test " + tableName + " " + description);
+ savedReport.setColumnsJson(JsonUtils.toJson(reportColumns));
+ savedReport.setQueryFilterJson(JsonUtils.toJson(queryFilter));
+ List reportRecordList = new InsertAction().execute(new InsertInput(SavedReport.TABLE_NAME).withRecordEntity(savedReport)).getRecords();
+
+ ///////////////////////
+ // render the report //
+ ///////////////////////
+ RunBackendStepInput input = new RunBackendStepInput();
+ RunBackendStepOutput output = new RunBackendStepOutput();
+
+ input.addValue(RenderSavedReportMetaDataProducer.FIELD_NAME_REPORT_FORMAT, ReportFormat.CSV.name());
+ input.addValue(RenderSavedReportMetaDataProducer.FIELD_NAME_STORAGE_TABLE_NAME, storageTableName);
+ input.setRecords(reportRecordList);
+
+ new RenderSavedReportExecuteStep().run(input, output);
+
+ //////////////////////////////////////////
+ // clean up the report, if so requested //
+ //////////////////////////////////////////
+ if(removeRenderedReports)
+ {
+ new DeleteAction().execute(new DeleteInput(RenderedReport.TABLE_NAME).withPrimaryKey(output.getValue("renderedReportId")));
+ }
+ }
+ catch(QException e)
+ {
+ caughtExceptions.put(Pair.of(tableName, description), e);
+ }
+ }
+
+ /*******************************************************************************
+ ** Getter for removeRenderedReports
+ *******************************************************************************/
+ public boolean getRemoveRenderedReports()
+ {
+ return (this.removeRenderedReports);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for removeRenderedReports
+ *******************************************************************************/
+ public void setRemoveRenderedReports(boolean removeRenderedReports)
+ {
+ this.removeRenderedReports = removeRenderedReports;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for removeRenderedReports
+ *******************************************************************************/
+ public ReportsFullInstanceVerifier withRemoveRenderedReports(boolean removeRenderedReports)
+ {
+ this.removeRenderedReports = removeRenderedReports;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for filterForAtMostOneRowPerReport
+ *******************************************************************************/
+ public boolean getFilterForAtMostOneRowPerReport()
+ {
+ return (this.filterForAtMostOneRowPerReport);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for filterForAtMostOneRowPerReport
+ *******************************************************************************/
+ public void setFilterForAtMostOneRowPerReport(boolean filterForAtMostOneRowPerReport)
+ {
+ this.filterForAtMostOneRowPerReport = filterForAtMostOneRowPerReport;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for filterForAtMostOneRowPerReport
+ *******************************************************************************/
+ public ReportsFullInstanceVerifier withFilterForAtMostOneRowPerReport(boolean filterForAtMostOneRowPerReport)
+ {
+ this.filterForAtMostOneRowPerReport = filterForAtMostOneRowPerReport;
+ return (this);
+ }
+
+
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsFullInstanceVerifierTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsFullInstanceVerifierTest.java
new file mode 100644
index 00000000..b2476f9b
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsFullInstanceVerifierTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.processes.implementations.columnstats;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.Test;
+
+
+/*******************************************************************************
+ ** Unit test for ColumnStatsFullInstanceVerifier
+ *******************************************************************************/
+class ColumnStatsFullInstanceVerifierTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ new ColumnStatsFullInstanceVerifier().verify(List.of(QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY)));
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/ReportsFullInstanceVerifierTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/ReportsFullInstanceVerifierTest.java
new file mode 100644
index 00000000..a3730f28
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/savedreports/ReportsFullInstanceVerifierTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.processes.implementations.savedreports;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+
+/*******************************************************************************
+ ** Unit test for ReportsFullInstanceVerifier
+ *******************************************************************************/
+class ReportsFullInstanceVerifierTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @BeforeEach
+ void beforeEach() throws Exception
+ {
+ new SavedReportsMetaDataProvider().defineAll(QContext.getQInstance(), TestUtils.MEMORY_BACKEND_NAME, TestUtils.MEMORY_BACKEND_NAME, null);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ new ReportsFullInstanceVerifier().verify(List.of(QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY)), SavedReportsMetaDataProvider.REPORT_STORAGE_TABLE_NAME);
+ }
+
+}
\ No newline at end of file