diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelper.java
new file mode 100644
index 00000000..10f68c2c
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelper.java
@@ -0,0 +1,88 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.customizers;
+
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+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.QFieldType;
+import com.kingsrook.qqq.backend.core.utils.collections.TypeTolerantKeyMap;
+
+
+/*******************************************************************************
+ ** utility class to help table customizers working with the oldRecordList.
+ ** Usage is just 2 lines:
+ ** outside of loop-over-records:
+ ** - OldRecordHelper oldRecordHelper = new OldRecordHelper(updateInput.getTableName(), oldRecordList);
+ ** then inside the record loop:
+ ** - Optional oldRecord = oldRecordHelper.getOldRecord(record);
+ *******************************************************************************/
+public class OldRecordHelper
+{
+ private String primaryKeyField;
+ private QFieldType primaryKeyType;
+
+ private Optional> oldRecordList;
+ private Map oldRecordMap;
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ public OldRecordHelper(String tableName, Optional> oldRecordList)
+ {
+ this.primaryKeyField = QContext.getQInstance().getTable(tableName).getPrimaryKeyField();
+ this.primaryKeyType = QContext.getQInstance().getTable(tableName).getField(primaryKeyField).getType();
+
+ this.oldRecordList = oldRecordList;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public Optional getOldRecord(QRecord record)
+ {
+ if(oldRecordMap == null)
+ {
+ if(oldRecordList.isPresent())
+ {
+ oldRecordMap = new TypeTolerantKeyMap<>(primaryKeyType);
+ oldRecordList.get().forEach(r -> oldRecordMap.put(r.getValue(primaryKeyField), r));
+ }
+ else
+ {
+ oldRecordMap = Collections.emptyMap();
+ }
+ }
+
+ return (Optional.ofNullable(oldRecordMap.get(record.getValue(primaryKeyField))));
+ }
+}
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 5ce2ce38..29266e13 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
@@ -54,6 +54,7 @@ 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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.joins.JoinOn;
@@ -174,9 +175,21 @@ public class InsertAction extends AbstractQActionFunction postInsertCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_INSERT_RECORD.getRole());
if(postInsertCustomizer.isPresent())
{
@@ -193,7 +206,25 @@ public class InsertAction extends AbstractQActionFunction tableCustomizerCodes = QContext.getQInstance().getTableCustomizers(TableCustomizers.POST_INSERT_RECORD);
+ for(QCodeReference tableCustomizerCode : tableCustomizerCodes)
+ {
+ try
+ {
+ TableCustomizerInterface tableCustomizer = QCodeLoader.getAdHoc(TableCustomizerInterface.class, tableCustomizerCode);
+ insertOutput.setRecords(tableCustomizer.postInsert(insertInput, insertOutput.getRecords()));
+ }
+ catch(Exception e)
+ {
+ for(QRecord record : insertOutput.getRecords())
+ {
+ record.addWarning(new QWarningMessage("An error occurred after the insert: " + e.getMessage()));
+ }
+ }
+ }
}
@@ -308,6 +339,19 @@ public class InsertAction extends AbstractQActionFunction tableCustomizerCodes = QContext.getQInstance().getTableCustomizers(TableCustomizers.PRE_INSERT_RECORD);
+ for(QCodeReference tableCustomizerCode : tableCustomizerCodes)
+ {
+ TableCustomizerInterface tableCustomizer = QCodeLoader.getAdHoc(TableCustomizerInterface.class, tableCustomizerCode);
+ if(whenToRun.equals(tableCustomizer.whenToRunPreInsert(insertInput, isPreview)))
+ {
+ insertInput.setRecords(tableCustomizer.preInsert(insertInput, insertInput.getRecords(), isPreview));
+ }
+ }
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java
index 30fcde0b..d3bd6ac5 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java
@@ -57,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
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.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
@@ -199,6 +200,18 @@ public class UpdateAction
//////////////////////////////////////////////////////////////
// finally, run the post-update customizer, if there is one //
//////////////////////////////////////////////////////////////
+ runPostUpdateCustomizers(updateInput, table, updateOutput, oldRecordList);
+
+ return updateOutput;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private static void runPostUpdateCustomizers(UpdateInput updateInput, QTableMetaData table, UpdateOutput updateOutput, Optional> oldRecordList)
+ {
Optional postUpdateCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_UPDATE_RECORD.getRole());
if(postUpdateCustomizer.isPresent())
{
@@ -215,7 +228,49 @@ public class UpdateAction
}
}
- return updateOutput;
+ ///////////////////////////////////////////////
+ // run all of the instance-level customizers //
+ ///////////////////////////////////////////////
+ List tableCustomizerCodes = QContext.getQInstance().getTableCustomizers(TableCustomizers.POST_UPDATE_RECORD);
+ for(QCodeReference tableCustomizerCode : tableCustomizerCodes)
+ {
+ try
+ {
+ TableCustomizerInterface tableCustomizer = QCodeLoader.getAdHoc(TableCustomizerInterface.class, tableCustomizerCode);
+ updateOutput.setRecords(tableCustomizer.postUpdate(updateInput, updateOutput.getRecords(), oldRecordList));
+ }
+ catch(Exception e)
+ {
+ for(QRecord record : updateOutput.getRecords())
+ {
+ record.addWarning(new QWarningMessage("An error occurred after the update: " + e.getMessage()));
+ }
+ }
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private static void runPreUpdateCustomizers(UpdateInput updateInput, QTableMetaData table, Optional> oldRecordList, boolean isPreview) throws QException
+ {
+ Optional preUpdateCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD.getRole());
+ if(preUpdateCustomizer.isPresent())
+ {
+ updateInput.setRecords(preUpdateCustomizer.get().preUpdate(updateInput, updateInput.getRecords(), isPreview, oldRecordList));
+ }
+
+ ///////////////////////////////////////////////
+ // run all of the instance-level customizers //
+ ///////////////////////////////////////////////
+ List tableCustomizerCodes = QContext.getQInstance().getTableCustomizers(TableCustomizers.PRE_UPDATE_RECORD);
+ for(QCodeReference tableCustomizerCode : tableCustomizerCodes)
+ {
+ TableCustomizerInterface tableCustomizer = QCodeLoader.getAdHoc(TableCustomizerInterface.class, tableCustomizerCode);
+ updateInput.setRecords(tableCustomizer.preUpdate(updateInput, updateInput.getRecords(), isPreview, oldRecordList));
+ }
}
@@ -278,11 +333,7 @@ public class UpdateAction
///////////////////////////////////////////////////////////////////////////
// after all validations, run the pre-update customizer, if there is one //
///////////////////////////////////////////////////////////////////////////
- Optional preUpdateCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD.getRole());
- if(preUpdateCustomizer.isPresent())
- {
- updateInput.setRecords(preUpdateCustomizer.get().preUpdate(updateInput, updateInput.getRecords(), isPreview, oldRecordList));
- }
+ runPreUpdateCustomizers(updateInput, table, oldRecordList, isPreview);
}
@@ -405,7 +456,7 @@ public class UpdateAction
QFieldType fieldType = table.getField(lock.getFieldName()).getType();
Serializable lockValue = ValueUtils.getValueAsFieldType(fieldType, oldRecord.getValue(lock.getFieldName()));
- List errors = ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE, Collections.emptyMap());
+ List errors = ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE, Collections.emptyMap(), QContext.getQSession());
if(CollectionUtils.nullSafeHasContents(errors))
{
errors.forEach(e -> record.addError(e));
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java
index ac0280d7..8a41e71b 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java
@@ -50,6 +50,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLockFilters;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage;
import com.kingsrook.qqq.backend.core.model.statusmessages.QErrorMessage;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
@@ -102,7 +103,7 @@ public class ValidateRecordSecurityLockHelper
// actually check lock values //
////////////////////////////////
Map errorRecords = new HashMap<>();
- evaluateRecordLocks(table, records, action, locksToCheck, errorRecords, new ArrayList<>(), madeUpPrimaryKeys, transaction);
+ evaluateRecordLocks(table, records, action, locksToCheck, errorRecords, new ArrayList<>(), madeUpPrimaryKeys, transaction, QContext.getQSession());
/////////////////////////////////
// propagate errors to records //
@@ -124,6 +125,29 @@ public class ValidateRecordSecurityLockHelper
+ /***************************************************************************
+ ** return boolean if given session can read given record
+ ***************************************************************************/
+ public static boolean allowedToReadRecord(QTableMetaData table, QRecord record, QSession qSession, QBackendTransaction transaction) throws QException
+ {
+ MultiRecordSecurityLock locksToCheck = getRecordSecurityLocks(table, Action.SELECT);
+ if(locksToCheck == null || CollectionUtils.nullSafeIsEmpty(locksToCheck.getLocks()))
+ {
+ return (true);
+ }
+
+ Map errorRecords = new HashMap<>();
+ evaluateRecordLocks(table, List.of(record), Action.SELECT, locksToCheck, errorRecords, new ArrayList<>(), Collections.emptyMap(), transaction, qSession);
+ if(errorRecords.containsKey(record.getValue(table.getPrimaryKeyField())))
+ {
+ return (false);
+ }
+
+ return (true);
+ }
+
+
+
/*******************************************************************************
** For a list of `records` from a `table`, and a given `action`, evaluate a
** `recordSecurityLock` (which may be a multi-lock) - populating the input map
@@ -142,7 +166,7 @@ public class ValidateRecordSecurityLockHelper
** BUT - WRITE locks - in their case, we read the record no matter what, and in
** here we need to verify we have a key that allows us to WRITE the record.
*******************************************************************************/
- private static void evaluateRecordLocks(QTableMetaData table, List records, Action action, RecordSecurityLock recordSecurityLock, Map errorRecords, List treePosition, Map madeUpPrimaryKeys, QBackendTransaction transaction) throws QException
+ private static void evaluateRecordLocks(QTableMetaData table, List records, Action action, RecordSecurityLock recordSecurityLock, Map errorRecords, List treePosition, Map madeUpPrimaryKeys, QBackendTransaction transaction, QSession qSession) throws QException
{
if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock)
{
@@ -153,7 +177,7 @@ public class ValidateRecordSecurityLockHelper
for(RecordSecurityLock childLock : CollectionUtils.nonNullList(multiRecordSecurityLock.getLocks()))
{
treePosition.add(i);
- evaluateRecordLocks(table, records, action, childLock, errorRecords, treePosition, madeUpPrimaryKeys, transaction);
+ evaluateRecordLocks(table, records, action, childLock, errorRecords, treePosition, madeUpPrimaryKeys, transaction, qSession);
treePosition.remove(treePosition.size() - 1);
i++;
}
@@ -165,7 +189,7 @@ public class ValidateRecordSecurityLockHelper
// if this lock has an all-access key, and the user has that key, then there can't be any errors here, so return early //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType());
- if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN))
+ if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && qSession.hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN))
{
return;
}
@@ -193,7 +217,7 @@ public class ValidateRecordSecurityLockHelper
}
Serializable recordSecurityValue = record.getValue(field.getName());
- List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action, madeUpPrimaryKeys);
+ List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action, madeUpPrimaryKeys, qSession);
if(CollectionUtils.nullSafeHasContents(recordErrors))
{
errorRecords.computeIfAbsent(record.getValue(primaryKeyField), (k) -> new RecordWithErrors(record)).addAll(recordErrors, treePosition);
@@ -339,7 +363,7 @@ public class ValidateRecordSecurityLockHelper
for(QRecord inputRecord : inputRecords)
{
- List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action, madeUpPrimaryKeys);
+ List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action, madeUpPrimaryKeys, qSession);
if(CollectionUtils.nullSafeHasContents(recordErrors))
{
errorRecords.computeIfAbsent(inputRecord.getValue(primaryKeyField), (k) -> new RecordWithErrors(inputRecord)).addAll(recordErrors, treePosition);
@@ -446,7 +470,7 @@ public class ValidateRecordSecurityLockHelper
/*******************************************************************************
**
*******************************************************************************/
- public static List validateRecordSecurityValue(QTableMetaData table, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action, Map madeUpPrimaryKeys)
+ public static List validateRecordSecurityValue(QTableMetaData table, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action, Map madeUpPrimaryKeys, QSession qSession)
{
if(recordSecurityValue == null || (madeUpPrimaryKeys != null && madeUpPrimaryKeys.containsKey(recordSecurityValue)))
{
@@ -461,7 +485,7 @@ public class ValidateRecordSecurityLockHelper
}
else
{
- if(!QContext.getQSession().hasSecurityKeyValue(recordSecurityLock.getSecurityKeyType(), recordSecurityValue, fieldType))
+ if(!qSession.hasSecurityKeyValue(recordSecurityLock.getSecurityKeyType(), recordSecurityValue, fieldType))
{
if(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()))
{
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java
index 8124351d..0817c585 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java
@@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.instances.enrichment.plugins.QInstanceEnri
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
@@ -211,6 +212,11 @@ public class QInstanceEnricher
***************************************************************************/
private void enrichInstance()
{
+ for(QSupplementalInstanceMetaData supplementalInstanceMetaData : qInstance.getSupplementalMetaData().values())
+ {
+ supplementalInstanceMetaData.enrich(qInstance);
+ }
+
runPlugins(QInstance.class, qInstance, qInstance);
}
@@ -1413,7 +1419,7 @@ public class QInstanceEnricher
if(possibleValueSource.getIdType() == null)
{
QTableMetaData table = qInstance.getTable(possibleValueSource.getTableName());
- if(table != null)
+ if(table != null && table.getFields() != null)
{
String primaryKeyField = table.getPrimaryKeyField();
QFieldMetaData primaryKeyFieldMetaData = CollectionUtils.nonNullMap(table.getFields()).get(primaryKeyField);
@@ -1486,7 +1492,18 @@ public class QInstanceEnricher
if(enrichMethod.isPresent())
{
Class> parameterType = enrichMethod.get().getParameterTypes()[0];
- enricherPlugins.add(parameterType, plugin);
+
+ Set existingPluginIdentifiers = enricherPlugins.getOrDefault(parameterType, Collections.emptyList())
+ .stream().map(p -> p.getPluginIdentifier())
+ .collect(Collectors.toSet());
+ if(existingPluginIdentifiers.contains(plugin.getPluginIdentifier()))
+ {
+ LOG.debug("Enricher plugin is already registered - not re-adding it", logPair("pluginIdentifer", plugin.getPluginIdentifier()));
+ }
+ else
+ {
+ enricherPlugins.add(parameterType, plugin);
+ }
}
else
{
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java
index a288c3b2..25b5543d 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java
@@ -42,6 +42,7 @@ import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationHandlerInterface;
+import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.metadata.JoinGraph;
@@ -253,6 +254,17 @@ public class QInstanceValidator
{
validateSimpleCodeReference("Instance metaDataActionCustomizer ", qInstance.getMetaDataActionCustomizer(), MetaDataActionCustomizerInterface.class);
}
+
+ if(qInstance.getTableCustomizers() != null)
+ {
+ for(Map.Entry> entry : qInstance.getTableCustomizers().entrySet())
+ {
+ for(QCodeReference codeReference : CollectionUtils.nonNullList(entry.getValue()))
+ {
+ validateSimpleCodeReference("Instance tableCustomizer of type " + entry.getKey() + ": ", codeReference, TableCustomizerInterface.class);
+ }
+ }
+ }
}
@@ -284,7 +296,18 @@ public class QInstanceValidator
if(validateMethod.isPresent())
{
Class> parameterType = validateMethod.get().getParameterTypes()[0];
- validatorPlugins.add(parameterType, plugin);
+
+ Set existingPluginIdentifiers = validatorPlugins.getOrDefault(parameterType, Collections.emptyList())
+ .stream().map(p -> p.getPluginIdentifier())
+ .collect(Collectors.toSet());
+ if(existingPluginIdentifiers.contains(plugin.getPluginIdentifier()))
+ {
+ LOG.debug("Validator plugin is already registered - not re-adding it", logPair("pluginIdentifer", plugin.getPluginIdentifier()));
+ }
+ else
+ {
+ validatorPlugins.add(parameterType, plugin);
+ }
}
else
{
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/Assessable.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/Assessable.java
new file mode 100644
index 00000000..48ba3802
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/Assessable.java
@@ -0,0 +1,37 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.instances.assessment;
+
+
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+
+
+/*******************************************************************************
+ ** marker for an object which can be processed by the QInstanceAssessor.
+ *******************************************************************************/
+public interface Assessable
+{
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ void assess(QInstanceAssessor qInstanceAssessor, QInstance qInstance);
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/QInstanceAssessor.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/QInstanceAssessor.java
new file mode 100644
index 00000000..08b25dfa
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/QInstanceAssessor.java
@@ -0,0 +1,215 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.instances.assessment;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+
+
+/*******************************************************************************
+ ** POC of a class that is meant to review meta-data for accuracy vs. real backends.
+ *******************************************************************************/
+public class QInstanceAssessor
+{
+ private static final QLogger LOG = QLogger.getLogger(QInstanceAssessor.class);
+
+ private final QInstance qInstance;
+
+ private List errors = new ArrayList<>();
+ private List warnings = new ArrayList<>();
+ private List suggestions = new ArrayList<>();
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ public QInstanceAssessor(QInstance qInstance)
+ {
+ this.qInstance = qInstance;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void assess()
+ {
+ for(QBackendMetaData backend : qInstance.getBackends().values())
+ {
+ if(backend instanceof Assessable assessable)
+ {
+ assessable.assess(this, qInstance);
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @SuppressWarnings("checkstyle:AvoidEscapedUnicodeCharacters")
+ public void printSummary()
+ {
+ ///////////////////////////
+ // print header & errors //
+ ///////////////////////////
+ if(CollectionUtils.nullSafeIsEmpty(errors))
+ {
+ System.out.println("Assessment passed with no errors! \uD83D\uDE0E");
+ }
+ else
+ {
+ System.out.println("Assessment found the following " + StringUtils.plural(errors, "error", "errors") + ": \uD83D\uDE32");
+
+ for(String error : errors)
+ {
+ System.out.println(" - " + error);
+ }
+ }
+
+ /////////////////////////////////////
+ // print warnings if there are any //
+ /////////////////////////////////////
+ if(CollectionUtils.nullSafeHasContents(warnings))
+ {
+ System.out.println("\nAssessment found the following " + StringUtils.plural(warnings, "warning", "warnings") + ": \uD83E\uDD28");
+
+ for(String warning : warnings)
+ {
+ System.out.println(" - " + warning);
+ }
+ }
+
+ //////////////////////////////////////////
+ // print suggestions, if there were any //
+ //////////////////////////////////////////
+ if(CollectionUtils.nullSafeHasContents(suggestions))
+ {
+ System.out.println("\nThe following " + StringUtils.plural(suggestions, "fix is", "fixes are") + " suggested: \uD83E\uDD13");
+
+ for(String suggestion : suggestions)
+ {
+ System.out.println("\n" + suggestion + "\n");
+ }
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for qInstance
+ **
+ *******************************************************************************/
+ public QInstance getInstance()
+ {
+ return qInstance;
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for errors
+ **
+ *******************************************************************************/
+ public List getErrors()
+ {
+ return errors;
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for warnings
+ **
+ *******************************************************************************/
+ public List getWarnings()
+ {
+ return warnings;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void addError(String errorMessage)
+ {
+ errors.add(errorMessage);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void addWarning(String warningMessage)
+ {
+ warnings.add(warningMessage);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void addError(String errorMessage, Exception e)
+ {
+ addError(errorMessage + " : " + e.getMessage());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void addSuggestion(String message)
+ {
+ suggestions.add(message);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public int getExitCode()
+ {
+ if(CollectionUtils.nullSafeHasContents(errors))
+ {
+ return (1);
+ }
+ else
+ {
+ return (0);
+ }
+ }
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/enrichment/plugins/QInstanceEnricherPluginInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/enrichment/plugins/QInstanceEnricherPluginInterface.java
index 30b07f61..cabb2772 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/enrichment/plugins/QInstanceEnricherPluginInterface.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/enrichment/plugins/QInstanceEnricherPluginInterface.java
@@ -37,4 +37,13 @@ public interface QInstanceEnricherPluginInterface
*******************************************************************************/
void enrich(T object, QInstance qInstance);
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ default String getPluginIdentifier()
+ {
+ return getClass().getName();
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/validation/plugins/QInstanceValidatorPluginInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/validation/plugins/QInstanceValidatorPluginInterface.java
index 9123d398..5276cbeb 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/validation/plugins/QInstanceValidatorPluginInterface.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/validation/plugins/QInstanceValidatorPluginInterface.java
@@ -38,4 +38,13 @@ public interface QInstanceValidatorPluginInterface
*******************************************************************************/
void validate(T object, QInstance qInstance, QInstanceValidator qInstanceValidator);
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ default String getPluginIdentifier()
+ {
+ return getClass().getName();
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/ProcessSummaryLineInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/ProcessSummaryLineInterface.java
index bb60e9ee..5575e706 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/ProcessSummaryLineInterface.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/processes/ProcessSummaryLineInterface.java
@@ -23,7 +23,11 @@ package com.kingsrook.qqq.backend.core.model.actions.processes;
import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
import com.kingsrook.qqq.backend.core.logging.LogPair;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -31,6 +35,45 @@ import com.kingsrook.qqq.backend.core.logging.LogPair;
*******************************************************************************/
public interface ProcessSummaryLineInterface extends Serializable
{
+ QLogger LOG = QLogger.getLogger(ProcessSummaryLineInterface.class);
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static void log(String message, Serializable summaryLines, List additionalLogPairs)
+ {
+ try
+ {
+ if(summaryLines instanceof List)
+ {
+ List list = (List) summaryLines;
+
+ List logPairs = new ArrayList<>();
+ for(ProcessSummaryLineInterface processSummaryLineInterface : list)
+ {
+ LogPair logPair = processSummaryLineInterface.toLogPair();
+ logPair.setKey(logPair.getKey() + logPairs.size());
+ logPairs.add(logPair);
+ }
+
+ if(additionalLogPairs != null)
+ {
+ logPairs.addAll(0, additionalLogPairs);
+ }
+ logPairs.add(0, logPair("message", message));
+
+ LOG.info(logPairs);
+ }
+ else
+ {
+ LOG.info("Unrecognized type for summaryLines (expected List)", logPair("processSummary", summaryLines));
+ }
+ }
+ catch(Exception e)
+ {
+ LOG.info("Error logging a process summary", e, logPair("processSummary", summaryLines));
+ }
+ }
/*******************************************************************************
** Getter for status
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerHelper.java
index 14a056ba..0e0f1fea 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerHelper.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerHelper.java
@@ -177,6 +177,18 @@ public class MetaDataProducerHelper
/////////////////////////////////////////////////////////////////////////////////////////////
// sort them by sort order, then by the type that they return, as set up in the static map //
/////////////////////////////////////////////////////////////////////////////////////////////
+ sortMetaDataProducers(producers);
+
+ return (producers);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static void sortMetaDataProducers(List> producers)
+ {
producers.sort(Comparator
.comparing((MetaDataProducerInterface> p) -> p.getSortOrder())
.thenComparing((MetaDataProducerInterface> p) ->
@@ -191,11 +203,10 @@ public class MetaDataProducerHelper
return (0);
}
}));
-
- return (producers);
}
+
/*******************************************************************************
** Recursively find all classes in the given package, that implement MetaDataProducerInterface
** run them, and add their output to the given qInstance.
@@ -417,7 +428,7 @@ public class MetaDataProducerHelper
return (null);
}
- ChildJoinFromRecordEntityGenericMetaDataProducer producer = new ChildJoinFromRecordEntityGenericMetaDataProducer(childTableName, parentTableName, possibleValueFieldName, childTable.childJoin().orderBy());
+ ChildJoinFromRecordEntityGenericMetaDataProducer producer = new ChildJoinFromRecordEntityGenericMetaDataProducer(childTableName, parentTableName, possibleValueFieldName, childTable.childJoin().orderBy(), childTable.childJoin().isOneToOne());
producer.setSourceClass(entityClass);
return producer;
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerMultiOutput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerMultiOutput.java
index 32c446a1..2ec4dea8 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerMultiOutput.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/MetaDataProducerMultiOutput.java
@@ -86,7 +86,7 @@ public class MetaDataProducerMultiOutput implements MetaDataProducerOutput, Sour
{
List rs = new ArrayList<>();
- for(MetaDataProducerOutput content : contents)
+ for(MetaDataProducerOutput content : CollectionUtils.nonNullList(contents))
{
if(content instanceof MetaDataProducerMultiOutput multiOutput)
{
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java
index 7b1fe014..49e64d17 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java
@@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.metadata;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -30,6 +31,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.metadata.JoinGraph;
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -65,6 +67,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.scheduler.schedulable.SchedulableType;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.ListingHash;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import io.github.cdimascio.dotenv.Dotenv;
import io.github.cdimascio.dotenv.DotenvEntry;
@@ -116,6 +119,8 @@ public class QInstance
private QPermissionRules defaultPermissionRules = QPermissionRules.defaultInstance();
private QAuditRules defaultAuditRules = QAuditRules.defaultInstanceLevelNone();
+ private ListingHash tableCustomizers;
+
@Deprecated(since = "migrated to metaDataCustomizer")
private QCodeReference metaDataFilter = null;
@@ -1623,4 +1628,76 @@ public class QInstance
return (this);
}
+
+
+ /*******************************************************************************
+ ** Getter for tableCustomizers
+ *******************************************************************************/
+ public ListingHash getTableCustomizers()
+ {
+ return (this.tableCustomizers);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for tableCustomizers
+ *******************************************************************************/
+ public void setTableCustomizers(ListingHash tableCustomizers)
+ {
+ this.tableCustomizers = tableCustomizers;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for tableCustomizers
+ *******************************************************************************/
+ public QInstance withTableCustomizers(ListingHash tableCustomizers)
+ {
+ this.tableCustomizers = tableCustomizers;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public QInstance withTableCustomizer(String role, QCodeReference customizer)
+ {
+ if(this.tableCustomizers == null)
+ {
+ this.tableCustomizers = new ListingHash<>();
+ }
+
+ this.tableCustomizers.add(role, customizer);
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public QInstance withTableCustomizer(TableCustomizers tableCustomizer, QCodeReference customizer)
+ {
+ return (withTableCustomizer(tableCustomizer.getRole(), customizer));
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for tableCustomizers
+ *******************************************************************************/
+ public List getTableCustomizers(TableCustomizers tableCustomizer)
+ {
+ if(this.tableCustomizers == null)
+ {
+ return (Collections.emptyList());
+ }
+
+ return (this.tableCustomizers.getOrDefault(tableCustomizer.getRole(), Collections.emptyList()));
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QSupplementalInstanceMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QSupplementalInstanceMetaData.java
index c6acf7a8..326a7c23 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QSupplementalInstanceMetaData.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QSupplementalInstanceMetaData.java
@@ -24,7 +24,6 @@ package com.kingsrook.qqq.backend.core.model.metadata;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
-import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
@@ -37,7 +36,7 @@ public interface QSupplementalInstanceMetaData extends TopLevelMetaDataInterface
/*******************************************************************************
**
*******************************************************************************/
- default void enrich(QTableMetaData table)
+ default void enrich(QInstance qInstance)
{
////////////////////////
// noop in base class //
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildJoinFromRecordEntityGenericMetaDataProducer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildJoinFromRecordEntityGenericMetaDataProducer.java
index 958b4900..725acb3d 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildJoinFromRecordEntityGenericMetaDataProducer.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildJoinFromRecordEntityGenericMetaDataProducer.java
@@ -26,12 +26,15 @@ import java.util.Objects;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin;
+import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
@@ -62,6 +65,7 @@ public class ChildJoinFromRecordEntityGenericMetaDataProducer implements MetaDat
private String childTableName; // e.g., lineItem
private String parentTableName; // e.g., order
private String foreignKeyFieldName; // e.g., orderId
+ private boolean isOneToOne;
private ChildJoin.OrderBy[] orderBys;
@@ -72,7 +76,7 @@ public class ChildJoinFromRecordEntityGenericMetaDataProducer implements MetaDat
/***************************************************************************
**
***************************************************************************/
- public ChildJoinFromRecordEntityGenericMetaDataProducer(String childTableName, String parentTableName, String foreignKeyFieldName, ChildJoin.OrderBy[] orderBys)
+ public ChildJoinFromRecordEntityGenericMetaDataProducer(String childTableName, String parentTableName, String foreignKeyFieldName, ChildJoin.OrderBy[] orderBys, boolean isOneToOne)
{
Objects.requireNonNull(childTableName, "childTableName cannot be null");
Objects.requireNonNull(parentTableName, "parentTableName cannot be null");
@@ -82,6 +86,7 @@ public class ChildJoinFromRecordEntityGenericMetaDataProducer implements MetaDat
this.parentTableName = parentTableName;
this.foreignKeyFieldName = foreignKeyFieldName;
this.orderBys = orderBys;
+ this.isOneToOne = isOneToOne;
}
@@ -92,23 +97,14 @@ public class ChildJoinFromRecordEntityGenericMetaDataProducer implements MetaDat
@Override
public QJoinMetaData produce(QInstance qInstance) throws QException
{
- QTableMetaData parentTable = qInstance.getTable(parentTableName);
- if(parentTable == null)
- {
- throw (new QException("Could not find tableMetaData " + parentTableName));
- }
-
- QTableMetaData childTable = qInstance.getTable(childTableName);
- if(childTable == null)
- {
- throw (new QException("Could not find tableMetaData " + childTable));
- }
+ QTableMetaData parentTable = getTable(qInstance, parentTableName);
+ QTableMetaData childTable = getTable(qInstance, childTableName);
QJoinMetaData join = new QJoinMetaData()
.withLeftTable(parentTableName)
.withRightTable(childTableName)
.withInferredName()
- .withType(JoinType.ONE_TO_MANY)
+ .withType(isOneToOne ? JoinType.ONE_TO_ONE : JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn(parentTable.getPrimaryKeyField(), foreignKeyFieldName));
if(orderBys != null && orderBys.length > 0)
@@ -131,6 +127,41 @@ public class ChildJoinFromRecordEntityGenericMetaDataProducer implements MetaDat
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ private QTableMetaData getTable(QInstance qInstance, String tableName) throws QException
+ {
+ QTableMetaData table = qInstance.getTable(tableName);
+ if(table == null)
+ {
+ ///////////////////////////////////////////////////////////////////////////////
+ // in case we're producing a QBit, and it's added a table to a multi-output, //
+ // but not yet the instance, see if we can get table from there //
+ ///////////////////////////////////////////////////////////////////////////////
+ for(MetaDataProducerMultiOutput metaDataProducerMultiOutput : QBitProductionContext.getReadOnlyViewOfMetaDataProducerMultiOutputStack())
+ {
+ table = CollectionUtils.nonNullList(metaDataProducerMultiOutput.getEach(QTableMetaData.class)).stream()
+ .filter(t -> t.getName().equals(tableName))
+ .findFirst().orElse(null);
+
+ if(table != null)
+ {
+ break;
+ }
+ }
+ }
+
+ if(table == null)
+ {
+ throw (new QException("Could not find tableMetaData: " + table));
+ }
+
+ return table;
+ }
+
+
+
/*******************************************************************************
** Getter for sourceClass
**
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildRecordListWidgetFromRecordEntityGenericMetaDataProducer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildRecordListWidgetFromRecordEntityGenericMetaDataProducer.java
index 6dac1a10..cce8ec82 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildRecordListWidgetFromRecordEntityGenericMetaDataProducer.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/ChildRecordListWidgetFromRecordEntityGenericMetaDataProducer.java
@@ -25,10 +25,13 @@ package com.kingsrook.qqq.backend.core.model.metadata.producers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
+import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -89,6 +92,26 @@ public class ChildRecordListWidgetFromRecordEntityGenericMetaDataProducer implem
String name = QJoinMetaData.makeInferredJoinName(parentTableName, childTableName);
QJoinMetaData join = qInstance.getJoin(name);
+ if(join == null)
+ {
+ for(MetaDataProducerMultiOutput metaDataProducerMultiOutput : QBitProductionContext.getReadOnlyViewOfMetaDataProducerMultiOutputStack())
+ {
+ join = CollectionUtils.nonNullList(metaDataProducerMultiOutput.getEach(QJoinMetaData.class)).stream()
+ .filter(t -> t.getName().equals(name))
+ .findFirst().orElse(null);
+
+ if(join != null)
+ {
+ break;
+ }
+ }
+ }
+
+ if(join == null)
+ {
+ throw (new QException("Could not find joinMetaData: " + name));
+ }
+
QWidgetMetaData widget = ChildRecordListRenderer.widgetMetaDataBuilder(join)
.withName(name)
.withLabel(childRecordListWidget.label())
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/annotations/ChildJoin.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/annotations/ChildJoin.java
index 5439bf02..4d2b15d3 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/annotations/ChildJoin.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/producers/annotations/ChildJoin.java
@@ -38,6 +38,8 @@ public @interface ChildJoin
OrderBy[] orderBy() default { };
+ boolean isOneToOne() default false;
+
/***************************************************************************
**
***************************************************************************/
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitConfig.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitConfig.java
index bb4ea5c1..9d9dd55a 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitConfig.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitConfig.java
@@ -107,4 +107,14 @@ public interface QBitConfig extends Serializable
{
return (null);
}
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ default String getDefaultBackendNameForTables()
+ {
+ return (null);
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitMetaDataProducer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitMetaDataProducer.java
new file mode 100644
index 00000000..c37f466b
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitMetaDataProducer.java
@@ -0,0 +1,192 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.metadata.qbits;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.exceptions.QRuntimeException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerHelper;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** 2nd generation interface for top-level meta-data production classes that make
+ ** a qbit (evolution over QBitProducer).
+ **
+ *******************************************************************************/
+public interface QBitMetaDataProducer extends MetaDataProducerInterface
+{
+ QLogger LOG = QLogger.getLogger(QBitMetaDataProducer.class);
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ C getQBitConfig();
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ QBitMetaData getQBitMetaData();
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ default String getNamespace()
+ {
+ return (null);
+ }
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ default void postProduceActions(MetaDataProducerMultiOutput metaDataProducerMultiOutput, QInstance qinstance)
+ {
+ /////////////////////
+ // noop by default //
+ /////////////////////
+ }
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ default String getPackageNameForFindingMetaDataProducers()
+ {
+ Class> clazz = getClass();
+
+ ////////////////////////////////////////////////////////////////
+ // Walk up the hierarchy until we find the direct implementer //
+ ////////////////////////////////////////////////////////////////
+ while(clazz != null)
+ {
+ Class>[] interfaces = clazz.getInterfaces();
+ for(Class> interfaze : interfaces)
+ {
+ if(interfaze == QBitMetaDataProducer.class)
+ {
+ return clazz.getPackageName();
+ }
+ }
+ clazz = clazz.getSuperclass();
+ }
+
+ throw (new QRuntimeException("Unable to find packageName for QBitMetaDataProducer. You may need to implement getPackageName yourself..."));
+ }
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ default MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
+ {
+ MetaDataProducerMultiOutput rs = new MetaDataProducerMultiOutput();
+
+ QBitMetaData qBitMetaData = getQBitMetaData();
+ C qBitConfig = getQBitConfig();
+
+ qInstance.addQBit(qBitMetaData);
+
+ QBitProductionContext.pushQBitConfig(qBitConfig);
+ QBitProductionContext.pushMetaDataProducerMultiOutput(rs);
+
+ try
+ {
+ qBitConfig.validate(qInstance);
+
+ List> producers = MetaDataProducerHelper.findProducers(getPackageNameForFindingMetaDataProducers());
+ MetaDataProducerHelper.sortMetaDataProducers(producers);
+ for(MetaDataProducerInterface> producer : producers)
+ {
+ if(producer.getClass().equals(this.getClass()))
+ {
+ /////////////////////////////////////////////
+ // avoid recursive processing of ourselves //
+ /////////////////////////////////////////////
+ continue;
+ }
+
+ ////////////////////////////////////////////////////////////////////////////
+ // todo is this deprecated in favor of QBitProductionContext's stack... ? //
+ ////////////////////////////////////////////////////////////////////////////
+ if(producer instanceof QBitComponentMetaDataProducer, ?>)
+ {
+ QBitComponentMetaDataProducer, C> qBitComponentMetaDataProducer = (QBitComponentMetaDataProducer, C>) producer;
+ qBitComponentMetaDataProducer.setQBitConfig(qBitConfig);
+ }
+
+ if(!producer.isEnabled())
+ {
+ LOG.debug("Not using producer which is not enabled", logPair("producer", producer.getClass().getSimpleName()));
+ continue;
+ }
+
+ MetaDataProducerOutput subProducerOutput = producer.produce(qInstance);
+
+ /////////////////////////////////////////////////
+ // apply some things from the config to tables //
+ /////////////////////////////////////////////////
+ if(subProducerOutput instanceof QTableMetaData table)
+ {
+ if(qBitConfig.getTableMetaDataCustomizer() != null)
+ {
+ subProducerOutput = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
+ }
+
+ if(!StringUtils.hasContent(table.getBackendName()) && StringUtils.hasContent(qBitConfig.getDefaultBackendNameForTables()))
+ {
+ table.setBackendName(qBitConfig.getDefaultBackendNameForTables());
+ }
+ }
+
+ ////////////////////////////////////////////////////////////
+ // set source qbit, if subProducerOutput is aware of such //
+ ////////////////////////////////////////////////////////////
+ if(subProducerOutput instanceof SourceQBitAware sourceQBitAware)
+ {
+ sourceQBitAware.setSourceQBitName(qBitMetaData.getName());
+ }
+
+ rs.add(subProducerOutput);
+ }
+
+ postProduceActions(rs, qInstance);
+
+ return (rs);
+ }
+ finally
+ {
+ QBitProductionContext.popQBitConfig();
+ QBitProductionContext.popMetaDataProducerMultiOutput();
+ }
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProducer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProducer.java
index 9e5e1a7d..3551a474 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProducer.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProducer.java
@@ -76,9 +76,6 @@ public interface QBitProducer
{
qBitConfig.validate(qInstance);
- ///////////////////////////////
- // todo - move to base class //
- ///////////////////////////////
for(MetaDataProducerInterface> producer : producers)
{
if(producer instanceof QBitComponentMetaDataProducerInterface,?>)
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProductionContext.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProductionContext.java
new file mode 100644
index 00000000..5c699ba7
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProductionContext.java
@@ -0,0 +1,136 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.metadata.qbits;
+
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Stack;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
+
+
+/*******************************************************************************
+ ** While a qbit is being produced, track the context of the current config
+ ** and metaDataProducerMultiOutput that is being used. also, in case one
+ ** qbit produces another, push these contextual objects on a stack.
+ *******************************************************************************/
+public class QBitProductionContext
+{
+ private static final QLogger LOG = QLogger.getLogger(QBitProductionContext.class);
+
+ private static Stack qbitConfigStack = new Stack<>();
+ private static Stack metaDataProducerMultiOutputStack = new Stack<>();
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static void pushQBitConfig(QBitConfig qBitConfig)
+ {
+ qbitConfigStack.push(qBitConfig);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static QBitConfig peekQBitConfig()
+ {
+ if(qbitConfigStack.isEmpty())
+ {
+ LOG.warn("Request to peek at empty QBitProductionContext configStack - returning null");
+ return (null);
+ }
+ return qbitConfigStack.peek();
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static void popQBitConfig()
+ {
+ if(qbitConfigStack.isEmpty())
+ {
+ LOG.warn("Request to pop empty QBitProductionContext configStack - returning with noop");
+ return;
+ }
+
+ qbitConfigStack.pop();
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static void pushMetaDataProducerMultiOutput(MetaDataProducerMultiOutput metaDataProducerMultiOutput)
+ {
+ metaDataProducerMultiOutputStack.push(metaDataProducerMultiOutput);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static MetaDataProducerMultiOutput peekMetaDataProducerMultiOutput()
+ {
+ if(metaDataProducerMultiOutputStack.isEmpty())
+ {
+ LOG.warn("Request to peek at empty QBitProductionContext configStack - returning null");
+ return (null);
+ }
+ return metaDataProducerMultiOutputStack.peek();
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static List getReadOnlyViewOfMetaDataProducerMultiOutputStack()
+ {
+ return Collections.unmodifiableList(metaDataProducerMultiOutputStack);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static void popMetaDataProducerMultiOutput()
+ {
+ if(metaDataProducerMultiOutputStack.isEmpty())
+ {
+ LOG.warn("Request to pop empty QBitProductionContext metaDataProducerMultiOutput - returning with noop");
+ return;
+ }
+
+ metaDataProducerMultiOutputStack.pop();
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java
index 04cb945b..c2a3f6aa 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java
@@ -44,7 +44,7 @@ public class MultiRecordSecurityLock extends RecordSecurityLock implements Clone
**
*******************************************************************************/
@Override
- protected MultiRecordSecurityLock clone() throws CloneNotSupportedException
+ public MultiRecordSecurityLock clone()
{
MultiRecordSecurityLock clone = (MultiRecordSecurityLock) super.clone();
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java
index 6cf99e9e..ca5239a8 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java
@@ -57,20 +57,27 @@ public class RecordSecurityLock implements Cloneable
**
*******************************************************************************/
@Override
- protected RecordSecurityLock clone() throws CloneNotSupportedException
+ public RecordSecurityLock clone()
{
- RecordSecurityLock clone = (RecordSecurityLock) super.clone();
-
- /////////////////////////
- // deep-clone the list //
- /////////////////////////
- if(joinNameChain != null)
+ try
{
- clone.joinNameChain = new ArrayList<>();
- clone.joinNameChain.addAll(joinNameChain);
- }
+ RecordSecurityLock clone = (RecordSecurityLock) super.clone();
- return (clone);
+ /////////////////////////
+ // deep-clone the list //
+ /////////////////////////
+ if(joinNameChain != null)
+ {
+ clone.joinNameChain = new ArrayList<>();
+ clone.joinNameChain.addAll(joinNameChain);
+ }
+
+ return (clone);
+ }
+ catch(CloneNotSupportedException e)
+ {
+ throw (new RuntimeException("Could not clone", e));
+ }
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableTableManager.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableTableManager.java
index d15aabb7..17dd4a38 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableTableManager.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableTableManager.java
@@ -22,18 +22,29 @@
package com.kingsrook.qqq.backend.core.model.tables;
+import java.io.Serializable;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
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.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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.processes.utils.GeneralProcessUtils;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -89,4 +100,41 @@ public class QQQTableTableManager
return getOutput.getRecord().getValueInteger("id");
}
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static List setRecordLinksToRecordsFromTableDynamicForPostQuery(QueryOrGetInputInterface queryInput, List records, String tableIdField, String recordIdField) throws QException
+ {
+ /////////////////////////////////////////////////////////////////////////////////////////
+ // note, this is a second copy of this logic (first being in standard process traces). //
+ // let the rule of 3 apply if we find ourselves copying it again //
+ /////////////////////////////////////////////////////////////////////////////////////////
+ if(queryInput.getShouldGenerateDisplayValues())
+ {
+ ///////////////////////////////////////////////////////////////////////////////////////////
+ // for records with a table id value - look up that table name, then set a display-value //
+ // for the Link type adornment, to the inputRecordId record within that table. //
+ ///////////////////////////////////////////////////////////////////////////////////////////
+ Set tableIds = records.stream().map(r -> r.getValue(tableIdField)).filter(Objects::nonNull).collect(Collectors.toSet());
+ if(!tableIds.isEmpty())
+ {
+ Map tableMap = GeneralProcessUtils.loadTableToMap(QQQTable.TABLE_NAME, "id", new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, tableIds)));
+
+ for(QRecord record : records)
+ {
+ QRecord qqqTableRecord = tableMap.get(record.getValue(tableIdField));
+ if(qqqTableRecord != null && record.getValue(recordIdField) != null)
+ {
+ record.setDisplayValue(recordIdField + ":" + AdornmentType.LinkValues.TO_RECORD_FROM_TABLE_DYNAMIC, qqqTableRecord.getValueString("name"));
+ }
+ }
+ }
+ }
+
+ return (records);
+ }
+
}
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 b549c1b1..0438ac81 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
@@ -81,6 +81,7 @@ import com.kingsrook.qqq.backend.core.utils.ListingHash;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import org.apache.commons.lang3.BooleanUtils;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -430,6 +431,9 @@ public class MemoryRecordStore
// differently from other backends, because of having the same record variable in the backend store and in the user-code. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
QRecord recordToInsert = new QRecord(record);
+
+ makeValueTypesMatchFieldTypes(table, recordToInsert);
+
if(CollectionUtils.nullSafeHasContents(recordToInsert.getErrors()))
{
outputRecords.add(recordToInsert);
@@ -504,6 +508,30 @@ public class MemoryRecordStore
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private static void makeValueTypesMatchFieldTypes(QTableMetaData table, QRecord recordToInsert)
+ {
+ for(QFieldMetaData field : table.getFields().values())
+ {
+ Serializable value = recordToInsert.getValue(field.getName());
+ if(value != null)
+ {
+ try
+ {
+ recordToInsert.setValue(field.getName(), ValueUtils.getValueAsFieldType(field.getType(), value));
+ }
+ catch(Exception e)
+ {
+ LOG.info("Error converting value to field's type", e, logPair("fieldName", field.getName()), logPair("value", value));
+ }
+ }
+ }
+ }
+
+
+
/*******************************************************************************
**
*******************************************************************************/
@@ -534,7 +562,19 @@ public class MemoryRecordStore
QRecord recordToUpdate = tableData.get(primaryKeyValue);
for(Map.Entry valueEntry : record.getValues().entrySet())
{
- recordToUpdate.setValue(valueEntry.getKey(), valueEntry.getValue());
+ String fieldName = valueEntry.getKey();
+ try
+ {
+ ///////////////////////////////////////////////
+ // try to make field values match field type //
+ ///////////////////////////////////////////////
+ recordToUpdate.setValue(fieldName, ValueUtils.getValueAsFieldType(table.getField(fieldName).getType(), valueEntry.getValue()));
+ }
+ catch(Exception e)
+ {
+ LOG.info("Error converting value to field's type", e, logPair("fieldName", fieldName), logPair("value", valueEntry.getValue()));
+ recordToUpdate.setValue(fieldName, valueEntry.getValue());
+ }
}
if(returnUpdatedRecords)
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcess.java
new file mode 100644
index 00000000..65ed317c
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcess.java
@@ -0,0 +1,262 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.scheduler.processes;
+
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+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.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.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+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.processes.QProcessMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QSchedulerMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob;
+import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobParameter;
+import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobType;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
+import com.kingsrook.qqq.backend.core.processes.implementations.tablesync.AbstractTableSyncTransformStep;
+import com.kingsrook.qqq.backend.core.processes.implementations.tablesync.TableSyncProcess;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder;
+
+
+/*******************************************************************************
+ ** Base class to manage creating scheduled jobs based on records in another table
+ **
+ ** Expected to be used via BaseSyncToScheduledJobTableCustomizer - see its javadoc.
+ **
+ *******************************************************************************/
+public abstract class AbstractRecordSyncToScheduledJobProcess extends AbstractTableSyncTransformStep implements MetaDataProducerInterface
+{
+ private static final QLogger LOG = QLogger.getLogger(AbstractRecordSyncToScheduledJobProcess.class);
+
+ public static final String SCHEDULER_NAME_FIELD_NAME = "schedulerName";
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public QProcessMetaData produce(QInstance qInstance) throws QException
+ {
+ QProcessMetaData processMetaData = TableSyncProcess.processMetaDataBuilder(false)
+ .withName(getClass().getSimpleName())
+ .withSyncTransformStepClass(getClass())
+ .withReviewStepRecordFields(List.of(
+ new QFieldMetaData(getRecordForeignKeyFieldName(), QFieldType.INTEGER).withPossibleValueSourceName(getRecordForeignKeyPossibleValueSourceName()),
+ new QFieldMetaData("cronExpression", QFieldType.STRING),
+ new QFieldMetaData("isActive", QFieldType.BOOLEAN)
+ ))
+ .getProcessMetaData();
+
+ processMetaData.getBackendStep(StreamedETLWithFrontendProcess.STEP_NAME_PREVIEW).getInputMetaData()
+ .withField(new QFieldMetaData(SCHEDULER_NAME_FIELD_NAME, QFieldType.STRING));
+
+ return (processMetaData);
+ }
+
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public QRecord populateRecordToStore(RunBackendStepInput runBackendStepInput, QRecord destinationRecord, QRecord sourceRecord) throws QException
+ {
+ ScheduledJob scheduledJob;
+ if(destinationRecord == null || destinationRecord.getValue("id") == null)
+ {
+ QInstance qInstance = QContext.getQInstance();
+
+ ////////////////////////////////////////////////////////////////
+ // this is the table at which the scheduled job will point to //
+ ////////////////////////////////////////////////////////////////
+ QTableMetaData sourceTableMetaData = qInstance.getTable(getSourceTableName());
+ String sourceTableId = String.valueOf(sourceRecord.getValueString(sourceTableMetaData.getPrimaryKeyField()));
+ String sourceTableJobKey = getSourceTableName() + "Id";
+
+ ///////////////////////////////////////////////////////////
+ // this is the table that the scheduled record points to //
+ ///////////////////////////////////////////////////////////
+ QTableMetaData recordForeignTableMetaData = qInstance.getTable(getRecordForeignKeyPossibleValueSourceName());
+ String sourceRecordForeignKeyId = sourceRecord.getValueString(getRecordForeignKeyFieldName());
+
+ ////////////////////////////////////////////////////////////////////////
+ // need to do an insert - set lots of key values in the scheduled job //
+ ////////////////////////////////////////////////////////////////////////
+ scheduledJob = new ScheduledJob();
+ scheduledJob.setSchedulerName(runBackendStepInput.getValueString(SCHEDULER_NAME_FIELD_NAME));
+ scheduledJob.setType(ScheduledJobType.PROCESS.name());
+ scheduledJob.setForeignKeyType(getSourceTableName());
+ scheduledJob.setForeignKeyValue(sourceTableId);
+ scheduledJob.setJobParameters(ListBuilder.of(
+ new ScheduledJobParameter().withKey("isScheduledJob").withValue("true"),
+ new ScheduledJobParameter().withKey("processName").withValue(getProcessNameScheduledJobParameter()),
+ new ScheduledJobParameter().withKey(sourceTableJobKey).withValue(sourceTableId),
+ new ScheduledJobParameter().withKey("recordId").withValue(ValueUtils.getValueAsString(sourceRecordForeignKeyId))
+ ));
+
+ //////////////////////////////////////////////////////////////////////////
+ // make a call to allow subclasses to customize parts of the job record //
+ //////////////////////////////////////////////////////////////////////////
+ scheduledJob.setLabel(recordForeignTableMetaData.getLabel() + " " + sourceRecordForeignKeyId);
+ scheduledJob.setDescription("Job to run " + sourceTableMetaData.getLabel() + " Id " + sourceTableId
+ + " (which runs for " + recordForeignTableMetaData.getLabel() + " Id " + sourceRecordForeignKeyId + ")");
+ }
+ else
+ {
+ //////////////////////////////////////////////////////////////////////////////////
+ // else doing an update - populate scheduled job entity from destination record //
+ //////////////////////////////////////////////////////////////////////////////////
+ scheduledJob = new ScheduledJob(destinationRecord);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////
+ // these fields sync on insert and update //
+ // todo - if no diffs, should we return null (to avoid changing quartz at all?) //
+ //////////////////////////////////////////////////////////////////////////////////
+ scheduledJob.setCronExpression(sourceRecord.getValueString("cronExpression"));
+ scheduledJob.setCronTimeZoneId(sourceRecord.getValueString("cronTimeZoneId"));
+ scheduledJob.setIsActive(true);
+
+ scheduledJob = customizeScheduledJob(scheduledJob, sourceRecord);
+
+ ////////////////////////////////////////////////////////////////////
+ // try to make sure scheduler name is set (and fail if it isn't!) //
+ ////////////////////////////////////////////////////////////////////
+ makeSureSchedulerNameIsSet(scheduledJob);
+
+ return scheduledJob.toQRecord();
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ protected void makeSureSchedulerNameIsSet(ScheduledJob scheduledJob) throws QException
+ {
+ if(!StringUtils.hasContent(scheduledJob.getSchedulerName()))
+ {
+ Map schedulers = QContext.getQInstance().getSchedulers();
+ if(schedulers.size() == 1)
+ {
+ scheduledJob.setSchedulerName(schedulers.keySet().iterator().next());
+ }
+ }
+
+ if(!StringUtils.hasContent(scheduledJob.getSchedulerName()))
+ {
+ String message = "Could not determine scheduler name for webhook scheduled job.";
+ LOG.warn(message);
+ throw (new QException(message));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected ScheduledJob customizeScheduledJob(ScheduledJob scheduledJob, QRecord sourceRecord) throws QException
+ {
+ return (scheduledJob);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ protected QQueryFilter getExistingRecordQueryFilter(RunBackendStepInput runBackendStepInput, List sourceKeyList)
+ {
+ return super.getExistingRecordQueryFilter(runBackendStepInput, sourceKeyList)
+ .withCriteria(new QFilterCriteria("foreignKeyType", QCriteriaOperator.EQUALS, getScheduledJobForeignKeyType()));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ protected SyncProcessConfig getSyncProcessConfig()
+ {
+ return new SyncProcessConfig(getSourceTableName(), getSourceTableKeyField(), ScheduledJob.TABLE_NAME, "foreignKeyValue", true, true);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected abstract String getScheduledJobForeignKeyType();
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected abstract String getRecordForeignKeyFieldName();
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected abstract String getRecordForeignKeyPossibleValueSourceName();
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected abstract String getSourceTableName();
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected abstract String getProcessNameScheduledJobParameter();
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected String getSourceTableKeyField()
+ {
+ return ("id");
+ }
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizer.java
new file mode 100644
index 00000000..aebd82c1
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizer.java
@@ -0,0 +1,339 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. 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.scheduler.processes;
+
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
+import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
+import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
+import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
+import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
+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.AbstractActionInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
+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.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob;
+import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ ** an implementation of a TableCustomizer that runs a subclass of
+ ** AbstractRecordSyncToScheduledJobProcess - to manage scheduledJob records that
+ ** correspond to records in another table (e.g., a job for each Client)
+ **
+ ** Easiest way to use is:
+ ** - BaseSyncToScheduledJobTableCustomizer.setTableCustomizers(tableMetaData, new YourSyncScheduledJobProcessSubclass());
+ ** which adds post-insert, -update, and -delete customizers to your table.
+ **
+ ** If you need additional table customizer code in those slots, I suppose you could
+ ** simply make your customizer create an instance of this class, set its
+ ** properties, and run its appropriate postInsertOrUpdate/postDelete methods.
+ *******************************************************************************/
+public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInterface, InitializableViaCodeReference
+{
+ private static final QLogger LOG = QLogger.getLogger(BaseSyncToScheduledJobTableCustomizer.class);
+
+ public static final String KEY_TABLE_NAME = "tableName";
+ public static final String KEY_SYNC_PROCESS_NAME = "syncProcessName";
+ public static final String KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE = "scheduledJobForeignKeyType";
+
+ private String tableName;
+ private String syncProcessName;
+ private String scheduledJobForeignKeyType;
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static void setTableCustomizers(QTableMetaData tableMetaData, AbstractRecordSyncToScheduledJobProcess syncProcess)
+ {
+ QCodeReference codeReference = new QCodeReferenceWithProperties(BaseSyncToScheduledJobTableCustomizer.class, Map.of(
+ KEY_TABLE_NAME, tableMetaData.getName(),
+ KEY_SYNC_PROCESS_NAME, syncProcess.getClass().getSimpleName(),
+ KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE, syncProcess.getScheduledJobForeignKeyType()
+ ));
+
+ tableMetaData.withCustomizer(TableCustomizers.POST_INSERT_RECORD, codeReference);
+ tableMetaData.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, codeReference);
+ tableMetaData.withCustomizer(TableCustomizers.POST_DELETE_RECORD, codeReference);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public void initialize(QCodeReference codeReference)
+ {
+ if(codeReference instanceof QCodeReferenceWithProperties codeReferenceWithProperties)
+ {
+ tableName = ValueUtils.getValueAsString(codeReferenceWithProperties.getProperties().get(KEY_TABLE_NAME));
+ syncProcessName = ValueUtils.getValueAsString(codeReferenceWithProperties.getProperties().get(KEY_SYNC_PROCESS_NAME));
+ scheduledJobForeignKeyType = ValueUtils.getValueAsString(codeReferenceWithProperties.getProperties().get(KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE));
+
+ if(!StringUtils.hasContent(tableName))
+ {
+ LOG.warn("Missing property under KEY_TABLE_NAME [" + KEY_TABLE_NAME + "] in codeReference for BaseSyncToScheduledJobTableCustomizer");
+ }
+
+ if(!StringUtils.hasContent(syncProcessName))
+ {
+ LOG.warn("Missing property under KEY_SYNC_PROCESS_NAME [" + KEY_SYNC_PROCESS_NAME + "] in codeReference for BaseSyncToScheduledJobTableCustomizer");
+ }
+
+ if(!StringUtils.hasContent(scheduledJobForeignKeyType))
+ {
+ LOG.warn("Missing property under KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE [" + KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE + "] in codeReference for BaseSyncToScheduledJobTableCustomizer");
+ }
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public List postInsertOrUpdate(AbstractActionInput input, List records, Optional> oldRecordList) throws QException
+ {
+ runSyncProcessForRecordList(records, syncProcessName);
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public List postDelete(DeleteInput deleteInput, List records) throws QException
+ {
+ deleteScheduledJobsForRecordList(records);
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public void runSyncProcessForRecordList(List records, String processName)
+ {
+ if(QContext.getQInstance().getTable(ScheduledJob.TABLE_NAME) == null)
+ {
+ LOG.info("ScheduledJob table not found, skipping scheduled job sync.");
+ return;
+ }
+
+ String primaryKeyField = QContext.getQInstance().getTable(tableName).getPrimaryKeyField();
+
+ List sourceRecordIds = records.stream()
+ .filter(r -> CollectionUtils.nullSafeIsEmpty(r.getErrors()))
+ .map(r -> r.getValue(primaryKeyField))
+ .filter(Objects::nonNull).toList();
+
+ if(CollectionUtils.nullSafeIsEmpty(sourceRecordIds))
+ {
+ return;
+ }
+
+ try
+ {
+ RunProcessInput runProcessInput = new RunProcessInput();
+ runProcessInput.setProcessName(processName);
+ runProcessInput.setCallback(QProcessCallbackFactory.forPrimaryKeys("id", sourceRecordIds));
+ runProcessInput.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
+ RunProcessOutput runProcessOutput = new RunProcessAction().execute(runProcessInput);
+
+ Serializable processSummary = runProcessOutput.getValue(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
+ ProcessSummaryLineInterface.log("Sync to ScheduledJob Process Summary", processSummary, List.of(logPair("sourceTable", tableName)));
+ }
+ catch(Exception e)
+ {
+ LOG.warn("Error syncing records to scheduled jobs", e, logPair("sourceTable", tableName), logPair("sourceRecordIds", sourceRecordIds));
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public void deleteScheduledJobsForRecordList(List records)
+ {
+ if(QContext.getQInstance().getTable(ScheduledJob.TABLE_NAME) == null)
+ {
+ LOG.info("ScheduledJob table not found, skipping scheduled job delete.");
+ return;
+ }
+
+ List sourceRecordIds = records.stream()
+ .filter(r -> CollectionUtils.nullSafeIsEmpty(r.getErrors()))
+ .map(r -> r.getValueString("id")).toList();
+
+ if(sourceRecordIds.isEmpty())
+ {
+ return;
+ }
+
+ ///////////////////////////////////////////////////
+ // delete any corresponding scheduledJob records //
+ ///////////////////////////////////////////////////
+ try
+ {
+ new DeleteAction().execute(new DeleteInput(ScheduledJob.TABLE_NAME).withQueryFilter(new QQueryFilter()
+ .withCriteria(new QFilterCriteria("foreignKeyType", QCriteriaOperator.EQUALS, getScheduledJobForeignKeyType()))
+ .withCriteria(new QFilterCriteria("foreignKeyValue", QCriteriaOperator.IN, sourceRecordIds))));
+ }
+ catch(Exception e)
+ {
+ LOG.warn("Error deleting scheduled jobs for scheduled records", e, logPair("sourceTable", tableName), logPair("sourceRecordIds", sourceRecordIds));
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for tableName
+ *******************************************************************************/
+ public String getTableName()
+ {
+ return (this.tableName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for tableName
+ *******************************************************************************/
+ public void setTableName(String tableName)
+ {
+ this.tableName = tableName;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for tableName
+ *******************************************************************************/
+ public BaseSyncToScheduledJobTableCustomizer withTableName(String tableName)
+ {
+ this.tableName = tableName;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for syncProcessName
+ *******************************************************************************/
+ public String getSyncProcessName()
+ {
+ return (this.syncProcessName);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for syncProcessName
+ *******************************************************************************/
+ public void setSyncProcessName(String syncProcessName)
+ {
+ this.syncProcessName = syncProcessName;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for syncProcessName
+ *******************************************************************************/
+ public BaseSyncToScheduledJobTableCustomizer withSyncProcessName(String syncProcessName)
+ {
+ this.syncProcessName = syncProcessName;
+ return (this);
+ }
+
+
+ /*******************************************************************************
+ ** Getter for KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE
+ *******************************************************************************/
+ public String getKEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE()
+ {
+ return (BaseSyncToScheduledJobTableCustomizer.KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for scheduledJobForeignKeyType
+ *******************************************************************************/
+ public String getScheduledJobForeignKeyType()
+ {
+ return (this.scheduledJobForeignKeyType);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for scheduledJobForeignKeyType
+ *******************************************************************************/
+ public void setScheduledJobForeignKeyType(String scheduledJobForeignKeyType)
+ {
+ this.scheduledJobForeignKeyType = scheduledJobForeignKeyType;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for scheduledJobForeignKeyType
+ *******************************************************************************/
+ public BaseSyncToScheduledJobTableCustomizer withScheduledJobForeignKeyType(String scheduledJobForeignKeyType)
+ {
+ this.scheduledJobForeignKeyType = scheduledJobForeignKeyType;
+ return (this);
+ }
+
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMap.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMap.java
new file mode 100644
index 00000000..bb718a76
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMap.java
@@ -0,0 +1,76 @@
+/*
+ * 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.utils.collections;
+
+
+import java.io.Serializable;
+import java.util.Map;
+import java.util.function.Supplier;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+
+
+/*******************************************************************************
+ ** Version of map where string keys are handled case-insensitively. e.g.,
+ ** map.put("One", 1); map.get("ONE") == 1.
+ *******************************************************************************/
+public class TypeTolerantKeyMap extends TransformedKeyMap
+{
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ public TypeTolerantKeyMap(QFieldType qFieldType)
+ {
+ super(key -> ValueUtils.getValueAsFieldType(qFieldType, key));
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ public TypeTolerantKeyMap(QFieldType qFieldType, Supplier