customizers = new ArrayList<>();
+
+
+ /***************************************************************************
+ * Factory method that builds a {@link QCodeReferenceWithProperties} that will
+ * allow this multi-customizer to be assigned to a table, and to track
+ * in that code ref's properties, the "sub" QCodeReferences to be used.
+ *
+ * Added to a table as in:
+ *
+ * table.withCustomizer(TableCustomizers.POST_INSERT_RECORD,
+ * MultiCustomizer.of(QCodeReference(x), QCodeReference(y)));
+ *
+ *
+ * @param codeReferences
+ * one or more {@link QCodeReference objects} to run when this customizer
+ * runs. note that they will run in the order provided in this list.
+ ***************************************************************************/
+ public static QCodeReferenceWithProperties of(QCodeReference... codeReferences)
+ {
+ ArrayList list = new ArrayList<>(Arrays.stream(codeReferences).toList());
+ return (new QCodeReferenceWithProperties(MultiCustomizer.class, MapBuilder.of(KEY_CODE_REFERENCES, list)));
+ }
+
+
+ /***************************************************************************
+ * Add an additional table customizer code reference to an existing
+ * codeReference, e.g., constructed by the `of` factory method.
+ *
+ * @see #of(QCodeReference...)
+ ***************************************************************************/
+ public static void addTableCustomizer(QCodeReferenceWithProperties existingMultiCustomizerCodeReference, QCodeReference codeReference)
+ {
+ ArrayList list = (ArrayList) existingMultiCustomizerCodeReference.getProperties().computeIfAbsent(KEY_CODE_REFERENCES, key -> new ArrayList<>());
+ list.add(codeReference);
+ }
+
+
+
+ /***************************************************************************
+ * When this class is instantiated by the QCodeLoader, initialize the
+ * sub-customizer objects.
+ ***************************************************************************/
+ @Override
+ public void initialize(QCodeReference codeReference)
+ {
+ if(codeReference instanceof QCodeReferenceWithProperties codeReferenceWithProperties)
+ {
+ Serializable codeReferencesPropertyValue = codeReferenceWithProperties.getProperties().get(KEY_CODE_REFERENCES);
+ if(codeReferencesPropertyValue instanceof List> list)
+ {
+ for(Object o : list)
+ {
+ if(o instanceof QCodeReference reference)
+ {
+ TableCustomizerInterface customizer = QCodeLoader.getAdHoc(TableCustomizerInterface.class, reference);
+ customizers.add(customizer);
+ }
+ }
+ }
+ else
+ {
+ LOG.warn("Property KEY_CODE_REFERENCES [" + KEY_CODE_REFERENCES + "] must be a List.");
+ }
+ }
+
+ if(customizers.isEmpty())
+ {
+ LOG.info("No TableCustomizers were specified for MultiCustomizer.");
+ }
+ }
+
+
+
+ /***************************************************************************
+ * run postQuery method over all sub-customizers
+ ***************************************************************************/
+ @Override
+ public List postQuery(QueryOrGetInputInterface queryInput, List records) throws QException
+ {
+ for(TableCustomizerInterface customizer : customizers)
+ {
+ records = customizer.postQuery(queryInput, records);
+ }
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ * run preInsert method over all sub-customizers
+ ***************************************************************************/
+ @Override
+ public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException
+ {
+ for(TableCustomizerInterface customizer : customizers)
+ {
+ records = customizer.preInsert(insertInput, records, isPreview);
+ }
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ * run postInsert method over all sub-customizers
+ ***************************************************************************/
+ @Override
+ public List postInsert(InsertInput insertInput, List records) throws QException
+ {
+ for(TableCustomizerInterface customizer : customizers)
+ {
+ records = customizer.postInsert(insertInput, records);
+ }
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ * run preUpdate method over all sub-customizers
+ ***************************************************************************/
+ @Override
+ public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException
+ {
+ for(TableCustomizerInterface customizer : customizers)
+ {
+ records = customizer.preUpdate(updateInput, records, isPreview, oldRecordList);
+ }
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ * run postUpdate method over all sub-customizers
+ ***************************************************************************/
+ @Override
+ public List postUpdate(UpdateInput updateInput, List records, Optional> oldRecordList) throws QException
+ {
+ for(TableCustomizerInterface customizer : customizers)
+ {
+ records = customizer.postUpdate(updateInput, records, oldRecordList);
+ }
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ * run preDelete method over all sub-customizers
+ ***************************************************************************/
+ @Override
+ public List preDelete(DeleteInput deleteInput, List records, boolean isPreview) throws QException
+ {
+ for(TableCustomizerInterface customizer : customizers)
+ {
+ records = customizer.preDelete(deleteInput, records, isPreview);
+ }
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ * run postDelete method over all sub-customizers
+ ***************************************************************************/
+ @Override
+ public List postDelete(DeleteInput deleteInput, List records) throws QException
+ {
+ for(TableCustomizerInterface customizer : customizers)
+ {
+ records = customizer.postDelete(deleteInput, records);
+ }
+ return records;
+ }
+
+}
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/DeleteAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java
index a5028027..17a96091 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java
@@ -401,6 +401,7 @@ public class DeleteAction
if(CollectionUtils.nullSafeHasContents(associatedKeys))
{
DeleteInput nextLevelDeleteInput = new DeleteInput();
+ nextLevelDeleteInput.setFlags(deleteInput.getFlags());
nextLevelDeleteInput.setTransaction(deleteInput.getTransaction());
nextLevelDeleteInput.setTableName(association.getAssociatedTableName());
nextLevelDeleteInput.setPrimaryKeys(associatedKeys);
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..e56da2b7 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
@@ -34,7 +34,6 @@ import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction;
import com.kingsrook.qqq.backend.core.actions.ActionHelper;
-import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus;
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater;
@@ -54,6 +53,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;
@@ -157,7 +157,7 @@ public class InsertAction extends AbstractQActionFunction postInsertCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_INSERT_RECORD.getRole());
if(postInsertCustomizer.isPresent())
{
@@ -193,7 +205,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 +338,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));
+ }
+ }
}
@@ -342,7 +385,7 @@ public class InsertAction extends AbstractQActionFunction insertedRecords, QBackendTransaction transaction) throws QException
+ private void manageAssociations(QTableMetaData table, List insertedRecords, InsertInput insertInput) throws QException
{
for(Association association : CollectionUtils.nonNullList(table.getAssociations()))
{
@@ -375,7 +418,8 @@ public class InsertAction extends AbstractQActionFunction> 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));
@@ -554,6 +605,7 @@ public class UpdateAction
{
LOG.debug("Deleting associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", queryOutput.getRecords().size()));
DeleteInput deleteInput = new DeleteInput();
+ deleteInput.setFlags(updateInput.getFlags());
deleteInput.setTransaction(updateInput.getTransaction());
deleteInput.setTableName(association.getAssociatedTableName());
deleteInput.setPrimaryKeys(queryOutput.getRecords().stream().map(r -> r.getValue(associatedTable.getPrimaryKeyField())).collect(Collectors.toList()));
@@ -566,6 +618,7 @@ public class UpdateAction
LOG.debug("Updating associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", nextLevelUpdates.size()));
UpdateInput nextLevelUpdateInput = new UpdateInput();
nextLevelUpdateInput.setTransaction(updateInput.getTransaction());
+ nextLevelUpdateInput.setFlags(updateInput.getFlags());
nextLevelUpdateInput.setTableName(association.getAssociatedTableName());
nextLevelUpdateInput.setRecords(nextLevelUpdates);
UpdateOutput nextLevelUpdateOutput = new UpdateAction().execute(nextLevelUpdateInput);
@@ -576,6 +629,7 @@ public class UpdateAction
LOG.debug("Inserting associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", nextLevelUpdates.size()));
InsertInput nextLevelInsertInput = new InsertInput();
nextLevelInsertInput.setTransaction(updateInput.getTransaction());
+ nextLevelInsertInput.setFlags(updateInput.getFlags());
nextLevelInsertInput.setTableName(association.getAssociatedTableName());
nextLevelInsertInput.setRecords(nextLevelInserts);
InsertOutput nextLevelInsertOutput = new InsertAction().execute(nextLevelInsertInput);
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/actions/values/BasicCustomPossibleValueProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/BasicCustomPossibleValueProvider.java
index da1421bc..fe6faa12 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/BasicCustomPossibleValueProvider.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/BasicCustomPossibleValueProvider.java
@@ -47,12 +47,12 @@ public abstract class BasicCustomPossibleValueProvider getAllSourceObjects();
+ protected abstract List getAllSourceObjects() throws QException;
@@ -60,7 +60,7 @@ public abstract class BasicCustomPossibleValueProvider getPossibleValue(Serializable idValue)
+ public QPossibleValue getPossibleValue(Serializable idValue) throws QException
{
S sourceObject = getSourceObject(idValue);
if(sourceObject == null)
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java
index d32e4e79..b27097a3 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java
@@ -45,7 +45,7 @@ public interface QCustomPossibleValueProvider
/*******************************************************************************
**
*******************************************************************************/
- QPossibleValue getPossibleValue(Serializable idValue);
+ QPossibleValue getPossibleValue(Serializable idValue) throws QException;
/*******************************************************************************
**
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..b1b9b2f3
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/QInstanceAssessor.java
@@ -0,0 +1,219 @@
+/*
+ * 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 String getSummary()
+ {
+ StringBuilder rs = new StringBuilder();
+
+ ///////////////////////////
+ // print header & errors //
+ ///////////////////////////
+ if(CollectionUtils.nullSafeIsEmpty(errors))
+ {
+ rs.append("Assessment passed with no errors! \uD83D\uDE0E\n");
+ }
+ else
+ {
+ rs.append("Assessment found the following ").append(StringUtils.plural(errors, "error", "errors")).append(": \uD83D\uDE32\n");
+
+ for(String error : errors)
+ {
+ rs.append(" - ").append(error).append("\n");
+ }
+ }
+
+ /////////////////////////////////////
+ // print warnings if there are any //
+ /////////////////////////////////////
+ if(CollectionUtils.nullSafeHasContents(warnings))
+ {
+ rs.append("\nAssessment found the following ").append(StringUtils.plural(warnings, "warning", "warnings")).append(": \uD83E\uDD28\n");
+
+ for(String warning : warnings)
+ {
+ rs.append(" - ").append(warning).append("\n");
+ }
+ }
+
+ //////////////////////////////////////////
+ // print suggestions, if there were any //
+ //////////////////////////////////////////
+ if(CollectionUtils.nullSafeHasContents(suggestions))
+ {
+ rs.append("\nThe following ").append(StringUtils.plural(suggestions, "fix is", "fixes are")).append(" suggested: \uD83E\uDD13\n");
+
+ for(String suggestion : suggestions)
+ {
+ rs.append("\n").append(suggestion).append("\n\n");
+ }
+ }
+
+ return (rs.toString());
+ }
+
+
+
+ /*******************************************************************************
+ ** 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/actions/tables/ActionFlag.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/ActionFlag.java
new file mode 100644
index 00000000..eacb5923
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/ActionFlag.java
@@ -0,0 +1,35 @@
+/*
+ * 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.actions.tables;
+
+
+import java.io.Serializable;
+
+
+/*******************************************************************************
+ ** interface to mark enums (presumably classes too, but the original intent is
+ ** enums) that can be added to insert/update/delete action inputs to flag behaviors
+ *******************************************************************************/
+public interface ActionFlag extends Serializable
+{
+
+}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java
index c7a92518..9c555f53 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java
@@ -24,9 +24,12 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.delete;
import java.io.Serializable;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.InputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
@@ -47,6 +50,8 @@ public class DeleteInput extends AbstractTableActionInput
private boolean omitDmlAudit = false;
private String auditContext = null;
+ private Set flags;
+
/*******************************************************************************
@@ -295,4 +300,65 @@ public class DeleteInput extends AbstractTableActionInput
return (this);
}
+
+
+ /*******************************************************************************
+ ** Getter for flags
+ *******************************************************************************/
+ public Set getFlags()
+ {
+ return (this.flags);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for flags
+ *******************************************************************************/
+ public void setFlags(Set flags)
+ {
+ this.flags = flags;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for flags
+ *******************************************************************************/
+ public DeleteInput withFlags(Set flags)
+ {
+ this.flags = flags;
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public DeleteInput withFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ this.flags = new HashSet<>();
+ }
+ this.flags.add(flag);
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public boolean hasFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ return (false);
+ }
+
+ return (this.flags.contains(flag));
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java
index 1753f61f..0bf2d301 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java
@@ -23,9 +23,12 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.insert;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.InputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -48,6 +51,8 @@ public class InsertInput extends AbstractTableActionInput
private boolean omitDmlAudit = false;
private String auditContext = null;
+ private Set flags;
+
/*******************************************************************************
@@ -316,4 +321,65 @@ public class InsertInput extends AbstractTableActionInput
return (this);
}
+
+
+ /*******************************************************************************
+ ** Getter for flags
+ *******************************************************************************/
+ public Set getFlags()
+ {
+ return (this.flags);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for flags
+ *******************************************************************************/
+ public void setFlags(Set flags)
+ {
+ this.flags = flags;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for flags
+ *******************************************************************************/
+ public InsertInput withFlags(Set flags)
+ {
+ this.flags = flags;
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public InsertInput withFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ this.flags = new HashSet<>();
+ }
+ this.flags.add(flag);
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public boolean hasFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ return (false);
+ }
+
+ return (this.flags.contains(flag));
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java
index e518dec1..542c8045 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java
@@ -22,9 +22,12 @@
package com.kingsrook.qqq.backend.core.model.actions.tables.replace;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
@@ -39,12 +42,14 @@ public class ReplaceInput extends AbstractTableActionInput
private UniqueKey key;
private List records;
private QQueryFilter filter;
- private boolean performDeletes = true;
- private boolean allowNullKeyValuesToEqual = false;
- private boolean setPrimaryKeyInInsertedRecords = false;
+ private boolean performDeletes = true;
+ private boolean allowNullKeyValuesToEqual = false;
+ private boolean setPrimaryKeyInInsertedRecords = false;
private boolean omitDmlAudit = false;
+ private Set flags;
+
/*******************************************************************************
@@ -303,4 +308,65 @@ public class ReplaceInput extends AbstractTableActionInput
return (this);
}
+
+
+ /*******************************************************************************
+ ** Getter for flags
+ *******************************************************************************/
+ public Set getFlags()
+ {
+ return (this.flags);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for flags
+ *******************************************************************************/
+ public void setFlags(Set flags)
+ {
+ this.flags = flags;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for flags
+ *******************************************************************************/
+ public ReplaceInput withFlags(Set flags)
+ {
+ this.flags = flags;
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public ReplaceInput withFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ this.flags = new HashSet<>();
+ }
+ this.flags.add(flag);
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public boolean hasFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ return (false);
+ }
+
+ return (this.flags.contains(flag));
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java
index 767b9ee5..d3645d37 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java
@@ -23,9 +23,12 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.update;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.InputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -56,6 +59,8 @@ public class UpdateInput extends AbstractTableActionInput
private boolean omitModifyDateUpdate = false;
private String auditContext = null;
+ private Set flags;
+
/*******************************************************************************
@@ -385,4 +390,65 @@ public class UpdateInput extends AbstractTableActionInput
return (this);
}
+
+
+ /*******************************************************************************
+ ** Getter for flags
+ *******************************************************************************/
+ public Set getFlags()
+ {
+ return (this.flags);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for flags
+ *******************************************************************************/
+ public void setFlags(Set flags)
+ {
+ this.flags = flags;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for flags
+ *******************************************************************************/
+ public UpdateInput withFlags(Set flags)
+ {
+ this.flags = flags;
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public UpdateInput withFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ this.flags = new HashSet<>();
+ }
+ this.flags.add(flag);
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public boolean hasFlag(ActionFlag flag)
+ {
+ if(this.flags == null)
+ {
+ return (false);
+ }
+
+ return (this.flags.contains(flag));
+ }
+
}
diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java
index fa06e309..33d21c04 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java
@@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.common;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import java.util.TimeZone;
import java.util.function.Function;
@@ -47,7 +48,7 @@ public class TimeZonePossibleValueSourceMetaDataProvider
*******************************************************************************/
public QPossibleValueSource produce()
{
- return (produce(null, null));
+ return (produce(null, null, null));
}
@@ -56,6 +57,16 @@ public class TimeZonePossibleValueSourceMetaDataProvider
**
*******************************************************************************/
public QPossibleValueSource produce(Predicate filter, Function labelMapper)
+ {
+ return (produce(filter, labelMapper, null));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public QPossibleValueSource produce(Predicate filter, Function labelMapper, Comparator> comparator)
{
QPossibleValueSource possibleValueSource = new QPossibleValueSource()
.withName("timeZones")
@@ -72,6 +83,11 @@ public class TimeZonePossibleValueSourceMetaDataProvider
}
}
+ if(comparator != null)
+ {
+ enumValues.sort(comparator);
+ }
+
possibleValueSource.withEnumValues(enumValues);
return (possibleValueSource);
}
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/QQQTableCustomPossibleValueProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java
new file mode 100644
index 00000000..b6ac044b
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java
@@ -0,0 +1,123 @@
+/*
+ * 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.tables;
+
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
+import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
+import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
+import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
+import com.kingsrook.qqq.backend.core.actions.values.BasicCustomPossibleValueProvider;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+
+
+/*******************************************************************************
+ ** possible-value source provider for the `QQQ Table` PVS - a list of all tables
+ ** in an application/qInstance (that you have permission to see)
+ *******************************************************************************/
+public class QQQTableCustomPossibleValueProvider extends BasicCustomPossibleValueProvider
+{
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected QPossibleValue makePossibleValue(QRecord sourceObject)
+ {
+ return (new QPossibleValue<>(sourceObject.getValueInteger("id"), sourceObject.getValueString("label")));
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected QRecord getSourceObject(Serializable id) throws QException
+ {
+ QRecord qqqTableRecord = GetAction.execute(QQQTable.TABLE_NAME, id);
+ if(qqqTableRecord == null)
+ {
+ return (null);
+ }
+
+ QTableMetaData table = QContext.getQInstance().getTable(qqqTableRecord.getValueString("name"));
+ return isTableAllowed(table) ? qqqTableRecord : null;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected List getAllSourceObjects() throws QException
+ {
+ List records = QueryAction.execute(QQQTable.TABLE_NAME, null);
+ ArrayList rs = new ArrayList<>();
+ for(QRecord record : records)
+ {
+ QTableMetaData table = QContext.getQInstance().getTable(record.getValueString("name"));
+ if(isTableAllowed(table))
+ {
+ rs.add(record);
+ }
+ }
+
+ return rs;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private boolean isTableAllowed(QTableMetaData table)
+ {
+ if(table == null)
+ {
+ return (false);
+ }
+
+ if(table.getIsHidden())
+ {
+ return (false);
+ }
+
+ PermissionCheckResult permissionCheckResult = PermissionsHelper.getPermissionCheckResult(new QueryInput(table.getName()), table);
+ if(!PermissionCheckResult.ALLOW.equals(permissionCheckResult))
+ {
+ return (false);
+ }
+
+ return (true);
+ }
+
+}
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/model/tables/QQQTablesMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTablesMetaDataProvider.java
index e880a230..91712ab9 100644
--- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTablesMetaDataProvider.java
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTablesMetaDataProvider.java
@@ -27,6 +27,9 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PVSValueFormatAndFields;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
@@ -125,10 +128,11 @@ public class QQQTablesMetaDataProvider
public QPossibleValueSource defineQQQTablePossibleValueSource()
{
return (new QPossibleValueSource()
- .withType(QPossibleValueSourceType.TABLE)
.withName(QQQTable.TABLE_NAME)
- .withTableName(QQQTable.TABLE_NAME))
- .withOrderByField("label");
+ .withType(QPossibleValueSourceType.CUSTOM)
+ .withIdType(QFieldType.INTEGER)
+ .withCustomCodeReference(new QCodeReference(QQQTableCustomPossibleValueProvider.class))
+ .withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY));
}
}
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..90df487b
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcess.java
@@ -0,0 +1,276 @@
+/*
+ * 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.ActionFlag;
+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.
+ * @see BaseSyncToScheduledJobTableCustomizer
+ *******************************************************************************/
+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";
+
+
+
+ /***************************************************************************
+ * action flags that can be put in an insert/update/delete input to control
+ * behavior of this process.
+ ***************************************************************************/
+ public enum ActionFlags implements ActionFlag
+ {
+ /***************************************************************************
+ * tell this process not to run upon such an action taken on the source table.
+ ***************************************************************************/
+ DO_NOT_SYNC
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @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..ce36a096
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizer.java
@@ -0,0 +1,387 @@
+/*
+ * 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.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
+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;
+
+
+
+ /***************************************************************************
+ * Create a {@link QCodeReferenceWithProperties} that can be used to add this
+ * class to a table.
+ *
+ * If this is the only customizer for the post insert/update/delete events
+ * on your table, you can instead call setTableCustomizers. But if you want,
+ * for example, a sync-scheduled-job (what this customizer does) plus some other
+ * customizers, then you can call this method to get a code reference that you
+ * can add, for example, to {@link com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer}
+ *
+ * @param tableMetaData the table that the customizer will be used on.
+ * @param syncProcess instance of the subclass of AbstractRecordSyncToScheduledJobProcess
+ * that should run in the table's post insert/update/delete
+ * events.
+ * @see #setTableCustomizers(QTableMetaData, AbstractRecordSyncToScheduledJobProcess)
+ ***************************************************************************/
+ public static QCodeReferenceWithProperties makeCodeReference(QTableMetaData tableMetaData, AbstractRecordSyncToScheduledJobProcess syncProcess)
+ {
+ return 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()
+ ));
+ }
+
+
+
+ /***************************************************************************
+ * Add post insert/update/delete customizers to a table, that will run a
+ * sync-scheduled-job process.
+ *
+ * @param tableMetaData the table that the customizer will be used on.
+ * @param syncProcess instance of the subclass of AbstractRecordSyncToScheduledJobProcess
+ * that should run in the table's post insert/update/delete
+ * events.
+ ***************************************************************************/
+ public static void setTableCustomizers(QTableMetaData tableMetaData, AbstractRecordSyncToScheduledJobProcess syncProcess)
+ {
+ QCodeReference codeReference = makeCodeReference(tableMetaData, syncProcess);
+ 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
+ {
+ if(input instanceof UpdateInput updateInput && updateInput.hasFlag(AbstractRecordSyncToScheduledJobProcess.ActionFlags.DO_NOT_SYNC))
+ {
+ return records;
+ }
+
+ if(input instanceof InsertInput insertInput && insertInput.hasFlag(AbstractRecordSyncToScheduledJobProcess.ActionFlags.DO_NOT_SYNC))
+ {
+ return records;
+ }
+
+ runSyncProcessForRecordList(records, syncProcessName);
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public List postDelete(DeleteInput deleteInput, List records) throws QException
+ {
+ deleteScheduledJobsForRecordList(records);
+ return records;
+ }
+
+
+
+ /***************************************************************************
+ * Run the named process over a set of records (e.g., that were inserted or
+ * updated).
+ *
+ * This method is normally called from within this class, in postInsertOrUpdate.
+ *
+ * Note that if the {@link ScheduledJob} table isn't defined in the QInstance,
+ * that the process will not be called.
+ *
+ * @param records list of records to use as source records in the table-sync
+ * to the scheduledJob table.
+ * @param processName name of the sync-process to run.
+ ***************************************************************************/
+ 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));
+ }
+ }
+
+
+
+ /***************************************************************************
+ * Delete scheduled job records for source-table records that have been deleted.
+ *
+ * This method is normally called from within this class, in postDelete.
+ *
+ * Note that if the {@link ScheduledJob} table isn't defined in the QInstance,
+ * that the process will not be called.
+ *
+ * @param records list of records to use as foreign-key sources to identify
+ * scheduledJob records to delete
+ ***************************************************************************/
+ 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 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> supplier)
+ {
+ super(key -> ValueUtils.getValueAsFieldType(qFieldType, key), supplier);
+ }
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ public TypeTolerantKeyMap(Class extends Serializable> c)
+ {
+ super(key -> ValueUtils.getValueAsType(c, key));
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ public TypeTolerantKeyMap(Class extends Serializable> c, Supplier> supplier)
+ {
+ super(key -> ValueUtils.getValueAsType(c, key), supplier);
+ }
+
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizerTest.java
new file mode 100644
index 00000000..64508617
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizerTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+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.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+/*******************************************************************************
+ ** Unit test for MultiCustomizer
+ *******************************************************************************/
+class MultiCustomizerTest extends BaseTest
+{
+ private static List events = new ArrayList<>();
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @BeforeEach
+ @AfterEach
+ void beforeAndAfterEach()
+ {
+ events.clear();
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ QInstance qInstance = TestUtils.defineInstance();
+ qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY)
+ .withCustomizer(TableCustomizers.PRE_INSERT_RECORD, MultiCustomizer.of(
+ new QCodeReference(CustomizerA.class),
+ new QCodeReference(CustomizerB.class)
+ ));
+ reInitInstanceInContext(qInstance);
+
+ new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecord(new QRecord()));
+ assertThat(events).hasSize(2)
+ .contains("CustomizerA.preInsert")
+ .contains("CustomizerB.preInsert");
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testAddingMore() throws QException
+ {
+ QInstance qInstance = TestUtils.defineInstance();
+
+ QCodeReferenceWithProperties multiCustomizer = MultiCustomizer.of(new QCodeReference(CustomizerA.class));
+ MultiCustomizer.addTableCustomizer(multiCustomizer, new QCodeReference(CustomizerB.class));
+
+ qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).withCustomizer(TableCustomizers.PRE_INSERT_RECORD, multiCustomizer);
+ reInitInstanceInContext(qInstance);
+
+ new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecord(new QRecord()));
+ assertThat(events).hasSize(2)
+ .contains("CustomizerA.preInsert")
+ .contains("CustomizerB.preInsert");
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static class CustomizerA implements TableCustomizerInterface
+ {
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException
+ {
+ events.add("CustomizerA.preInsert");
+ return (records);
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static class CustomizerB implements TableCustomizerInterface
+ {
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException
+ {
+ events.add("CustomizerB.preInsert");
+ return (records);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelperTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelperTest.java
new file mode 100644
index 00000000..69e0731f
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelperTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.util.List;
+import java.util.Optional;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+/*******************************************************************************
+ ** Unit test for OldRecordHelper
+ *******************************************************************************/
+class OldRecordHelperTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test()
+ {
+ OldRecordHelper oldRecordHelper = new OldRecordHelper(TestUtils.TABLE_NAME_PERSON_MEMORY, Optional.of(List.of(
+ new QRecord().withValue("id", 1)
+ )));
+
+ assertTrue(oldRecordHelper.getOldRecord(new QRecord().withValue("id", 1)).isPresent());
+ assertTrue(oldRecordHelper.getOldRecord(new QRecord().withValue("id", "1")).isPresent());
+ assertFalse(oldRecordHelper.getOldRecord(new QRecord().withValue("id", 2)).isPresent());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testEmptyOldRecords()
+ {
+ OldRecordHelper oldRecordHelper = new OldRecordHelper(TestUtils.TABLE_NAME_PERSON_MEMORY, Optional.empty());
+ assertFalse(oldRecordHelper.getOldRecord(new QRecord().withValue("id", 1)).isPresent());
+ assertFalse(oldRecordHelper.getOldRecord(new QRecord().withValue("id", "1")).isPresent());
+ assertFalse(oldRecordHelper.getOldRecord(new QRecord().withValue("id", 2)).isPresent());
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionInstanceLevelTableCustomizersTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionInstanceLevelTableCustomizersTest.java
new file mode 100644
index 00000000..4f81c0f1
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionInstanceLevelTableCustomizersTest.java
@@ -0,0 +1,150 @@
+/*
+ * 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.tables;
+
+
+import java.util.List;
+import java.util.Optional;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
+import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+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.statusmessages.SystemErrorStatusMessage;
+import com.kingsrook.qqq.backend.core.utils.ListingHash;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class InsertActionInstanceLevelTableCustomizersTest extends BaseTest
+{
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testInstanceLevelCustomizers() throws QException
+ {
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(BreaksEverythingCustomizer.class));
+ QRecord record = new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("name", "octogon"))).getRecords().get(0);
+ assertEquals("Everything is broken", record.getErrorsAsString());
+ assertNull(record.getValueInteger("id"));
+
+ QContext.getQInstance().setTableCustomizers(new ListingHash<>());
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(SetsFirstName.class));
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(SetsLastName.class));
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.POST_INSERT_RECORD, new QCodeReference(DoesNothing.class));
+ DoesNothing.callCount = 0;
+ record = new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("name", "octogon"))).getRecords().get(0);
+ assertEquals("Jeff", record.getValueString("firstName"));
+ assertEquals("Smith", record.getValueString("lastName"));
+ assertNotNull(record.getValueInteger("id"));
+ assertEquals(1, DoesNothing.callCount);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static class BreaksEverythingCustomizer implements TableCustomizerInterface
+ {
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public List preInsertOrUpdate(AbstractActionInput input, List records, boolean isPreview, Optional> oldRecordList) throws QException
+ {
+ records.forEach(r -> r.addError(new SystemErrorStatusMessage("Everything is broken")));
+ return records;
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static class SetsFirstName implements TableCustomizerInterface
+ {
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public List preInsertOrUpdate(AbstractActionInput input, List records, boolean isPreview, Optional> oldRecordList) throws QException
+ {
+ records.forEach(r -> r.setValue("firstName", "Jeff"));
+ return records;
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static class SetsLastName implements TableCustomizerInterface
+ {
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public List preInsertOrUpdate(AbstractActionInput input, List records, boolean isPreview, Optional> oldRecordList) throws QException
+ {
+ records.forEach(r -> r.setValue("lastName", "Smith"));
+ return records;
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static class DoesNothing implements TableCustomizerInterface
+ {
+ static int callCount = 0;
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ @Override
+ public List postInsertOrUpdate(AbstractActionInput input, List records, Optional> oldRecordList) throws QException
+ {
+ callCount++;
+ return records;
+ }
+ }
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionInstanceLevelTableCustomizersTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionInstanceLevelTableCustomizersTest.java
new file mode 100644
index 00000000..2f86e8eb
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionInstanceLevelTableCustomizersTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.tables;
+
+
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
+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.utils.ListingHash;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class UpdateActionInstanceLevelTableCustomizersTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testInstanceLevelCustomizers() throws QException
+ {
+ QRecord record = new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("name", "octogon"))).getRecords().get(0);
+
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(InsertActionInstanceLevelTableCustomizersTest.BreaksEverythingCustomizer.class));
+ record = new UpdateAction().execute(new UpdateInput(TestUtils.TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("id", record.getValue("id")).withValue("name", "octogon"))).getRecords().get(0);
+ assertEquals("Everything is broken", record.getErrorsAsString());
+
+ QContext.getQInstance().setTableCustomizers(new ListingHash<>());
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(InsertActionInstanceLevelTableCustomizersTest.SetsFirstName.class));
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(InsertActionInstanceLevelTableCustomizersTest.SetsLastName.class));
+ QContext.getQInstance().withTableCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(InsertActionInstanceLevelTableCustomizersTest.DoesNothing.class));
+ InsertActionInstanceLevelTableCustomizersTest.DoesNothing.callCount = 0;
+ record = new UpdateAction().execute(new UpdateInput(TestUtils.TABLE_NAME_SHAPE).withRecord(new QRecord().withValue("id", record.getValue("id")).withValue("name", "octogon"))).getRecords().get(0);
+ assertEquals("Jeff", record.getValueString("firstName"));
+ assertEquals("Smith", record.getValueString("lastName"));
+ assertNotNull(record.getValueInteger("id"));
+ assertEquals(1, InsertActionInstanceLevelTableCustomizersTest.DoesNothing.callCount);
+ }
+
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java
index 6186ad4e..8cb6a1db 100644
--- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java
@@ -23,14 +23,22 @@ package com.kingsrook.qqq.backend.core.actions.tables.helpers;
import java.util.List;
+import java.util.Map;
import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper.RecordWithErrors;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
+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.BadInputStatusMessage;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock.BooleanOperator.AND;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
@@ -106,4 +114,29 @@ class ValidateRecordSecurityLockHelperTest extends BaseTest
}
}
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testAllowedToReadRecord() throws QException
+ {
+ QTableMetaData table = QContext.getQInstance().getTables().get(TestUtils.TABLE_NAME_ORDER);
+
+ QSession sessionWithStore1 = new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 1);
+ QSession sessionWithStore2 = new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 2);
+ QSession sessionWithStore1and2 = new QSession().withSecurityKeyValues(Map.of(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(1, 2)));
+ QSession sessionWithStoresAllAccess = new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE_ALL_ACCESS, true);
+ QSession sessionWithNoStores = new QSession();
+
+ QRecord recordStore1 = new QRecord().withValue("storeId", 1);
+
+ assertTrue(ValidateRecordSecurityLockHelper.allowedToReadRecord(table, recordStore1, sessionWithStore1, null));
+ assertFalse(ValidateRecordSecurityLockHelper.allowedToReadRecord(table, recordStore1, sessionWithStore2, null));
+ assertTrue(ValidateRecordSecurityLockHelper.allowedToReadRecord(table, recordStore1, sessionWithStore1and2, null));
+ assertTrue(ValidateRecordSecurityLockHelper.allowedToReadRecord(table, recordStore1, sessionWithStoresAllAccess, null));
+ assertFalse(ValidateRecordSecurityLockHelper.allowedToReadRecord(table, recordStore1, sessionWithNoStores, null));
+ }
+
}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java
index d790ca59..04ff5a41 100644
--- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java
@@ -176,6 +176,30 @@ public class QInstanceValidatorTest extends BaseTest
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testInstanceLevelTableCustomizers()
+ {
+ assertValidationFailureReasons((qInstance) -> qInstance.withTableCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(QInstanceValidator.class)),
+ "Instance tableCustomizer of type preInsertRecord: CodeReference is not of the expected type");
+
+ assertValidationFailureReasons((qInstance) ->
+ {
+ qInstance.withTableCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(QInstanceValidator.class));
+ qInstance.withTableCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(QInstanceValidator.class));
+ qInstance.withTableCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(QInstanceValidator.class));
+ },
+ "Instance tableCustomizer of type postUpdateRecord: CodeReference is not of the expected type",
+ "Instance tableCustomizer of type postUpdateRecord: CodeReference is not of the expected type",
+ "Instance tableCustomizer of type preDeleteRecord: CodeReference is not of the expected type");
+
+ assertValidationSuccess((qInstance) -> qInstance.withTableCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(CustomizerValid.class)));
+ }
+
+
+
/*******************************************************************************
** Test an instance with null backends - should throw.
**
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java
index 4bdbbc06..5bf5217b 100644
--- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java
@@ -80,7 +80,7 @@ class TablesCustomPossibleValueProviderTest extends BaseTest
**
*******************************************************************************/
@Test
- void testGetPossibleValue()
+ void testGetPossibleValue() throws QException
{
TablesCustomPossibleValueProvider provider = new TablesCustomPossibleValueProvider();
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java
new file mode 100644
index 00000000..6412df35
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java
@@ -0,0 +1,174 @@
+/*
+ * 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.tables;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
+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.permissions.PermissionLevel;
+import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+
+/*******************************************************************************
+ ** Unit test for QQQTableCustomPossibleValueProvider
+ *******************************************************************************/
+class QQQTableCustomPossibleValueProviderTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @BeforeEach
+ void beforeEach() throws QException
+ {
+ QInstance qInstance = TestUtils.defineInstance();
+
+ qInstance.addTable(new QTableMetaData()
+ .withName("hidden")
+ .withIsHidden(true)
+ .withBackendName(TestUtils.MEMORY_BACKEND_NAME)
+ .withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER)));
+
+ qInstance.addTable(new QTableMetaData()
+ .withName("restricted")
+ .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION))
+ .withBackendName(TestUtils.MEMORY_BACKEND_NAME)
+ .withPrimaryKeyField("id")
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER)));
+
+ new QQQTablesMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, TestUtils.MEMORY_BACKEND_NAME, null);
+
+ QContext.init(qInstance, newSession());
+
+ for(String tableName : qInstance.getTables().keySet())
+ {
+ QQQTableTableManager.getQQQTableId(qInstance, tableName);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testGetPossibleValue() throws QException
+ {
+ Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_PERSON);
+ QQQTableCustomPossibleValueProvider provider = new QQQTableCustomPossibleValueProvider();
+
+ QPossibleValue possibleValue = provider.getPossibleValue(personTableId);
+ assertEquals(personTableId, possibleValue.getId());
+ assertEquals("Person", possibleValue.getLabel());
+
+ assertNull(provider.getPossibleValue(-1));
+
+ Integer hiddenTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "hidden");
+ assertNull(provider.getPossibleValue(hiddenTableId));
+
+ Integer restrictedTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "restricted");
+ assertNull(provider.getPossibleValue(restrictedTableId));
+
+ QContext.getQSession().withPermission("restricted.hasAccess");
+ assertNotNull(provider.getPossibleValue(restrictedTableId));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testSearchPossibleValue() throws QException
+ {
+ Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_PERSON);
+ Integer shapeTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_SHAPE);
+ Integer hiddenTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "hidden");
+ Integer restrictedTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "restricted");
+
+ QQQTableCustomPossibleValueProvider provider = new QQQTableCustomPossibleValueProvider();
+
+ List> list = provider.search(new SearchPossibleValueSourceInput()
+ .withPossibleValueSourceName(QQQTable.TABLE_NAME));
+ assertThat(list).anyMatch(p -> p.getId().equals(personTableId));
+ assertThat(list).noneMatch(p -> p.getId().equals(-1));
+ assertThat(list).noneMatch(p -> p.getId().equals(hiddenTableId));
+ assertThat(list).noneMatch(p -> p.getId().equals(restrictedTableId));
+ assertNull(provider.getPossibleValue("restricted"));
+
+ list = provider.search(new SearchPossibleValueSourceInput()
+ .withPossibleValueSourceName(QQQTable.TABLE_NAME)
+ .withIdList(List.of(personTableId, shapeTableId, hiddenTableId)));
+ assertEquals(2, list.size());
+ assertThat(list).anyMatch(p -> p.getId().equals(personTableId));
+ assertThat(list).anyMatch(p -> p.getId().equals(shapeTableId));
+ assertThat(list).noneMatch(p -> p.getId().equals(hiddenTableId));
+
+ list = provider.search(new SearchPossibleValueSourceInput()
+ .withPossibleValueSourceName(QQQTable.TABLE_NAME)
+ .withLabelList(List.of("Person", "Shape", "Restricted")));
+ assertEquals(2, list.size());
+ assertThat(list).anyMatch(p -> p.getId().equals(personTableId));
+ assertThat(list).anyMatch(p -> p.getId().equals(shapeTableId));
+ assertThat(list).noneMatch(p -> p.getId().equals(restrictedTableId));
+
+ list = provider.search(new SearchPossibleValueSourceInput()
+ .withPossibleValueSourceName(QQQTable.TABLE_NAME)
+ .withSearchTerm("restricted"));
+ assertEquals(0, list.size());
+
+ /////////////////////////////////////////
+ // add permission for restricted table //
+ /////////////////////////////////////////
+ QContext.getQSession().withPermission("restricted.hasAccess");
+ list = provider.search(new SearchPossibleValueSourceInput()
+ .withPossibleValueSourceName(QQQTable.TABLE_NAME)
+ .withSearchTerm("restricted"));
+ assertEquals(1, list.size());
+
+ list = provider.search(new SearchPossibleValueSourceInput()
+ .withPossibleValueSourceName(QQQTable.TABLE_NAME)
+ .withLabelList(List.of("Person", "Shape", "Restricted")));
+ assertEquals(3, list.size());
+ assertThat(list).anyMatch(p -> p.getId().equals(personTableId));
+ assertThat(list).anyMatch(p -> p.getId().equals(shapeTableId));
+ assertThat(list).anyMatch(p -> p.getId().equals(restrictedTableId));
+
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcessTest.java
new file mode 100644
index 00000000..bc67ea38
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcessTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.util.List;
+import com.kingsrook.qqq.backend.core.BaseTest;
+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.InsertAction;
+import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
+import com.kingsrook.qqq.backend.core.model.common.TimeZonePossibleValueSourceMetaDataProvider;
+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.scheduledjobs.ScheduledJob;
+import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobsMetaDataProvider;
+import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
+import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+/*******************************************************************************
+ ** Unit test for AbstractRecordSyncToScheduledJobProcess
+ *******************************************************************************/
+class AbstractRecordSyncToScheduledJobProcessTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @BeforeEach
+ void beforeEach() throws QException
+ {
+ QInstance qInstance = QContext.getQInstance();
+ new ScheduledJobsMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null);
+ qInstance.addProcess(new SyncPersonToScheduledJobProcess().produce(qInstance));
+ qInstance.addPossibleValueSource(new TimeZonePossibleValueSourceMetaDataProvider().produce());
+ QScheduleManager.initInstance(qInstance, QSystemUserSession::new);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ QRecord person = new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY)
+ .withRecord(new QRecord().withValue("id", 1701).withValue("firstName", "Darin")))
+ .getRecords().get(0);
+
+ RunProcessInput input = new RunProcessInput();
+ input.setProcessName(SyncPersonToScheduledJobProcess.class.getSimpleName());
+ input.setCallback(QProcessCallbackFactory.forRecord(person));
+ input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
+ new RunProcessAction().execute(input);
+
+ List scheduledJobs = new QueryAction().execute(new QueryInput(ScheduledJob.TABLE_NAME).withIncludeAssociations(true)).getRecordEntities(ScheduledJob.class);
+ assertEquals(1, scheduledJobs.size());
+ ScheduledJob scheduledJob = scheduledJobs.get(0);
+ assertEquals(TestUtils.TABLE_NAME_PERSON_MEMORY, scheduledJob.getForeignKeyType());
+ assertEquals(person.getValueString("id"), scheduledJob.getForeignKeyValue());
+ assertEquals(60, scheduledJob.getRepeatSeconds());
+ assertTrue(scheduledJob.getIsActive());
+ assertEquals(4, scheduledJob.getJobParameters().size());
+ assertEquals(TestUtils.PROCESS_NAME_GREET_PEOPLE, scheduledJob.getJobParameters().stream().filter(jp -> jp.getKey().equals("processName")).findFirst().get().getValue());
+ assertEquals("true", scheduledJob.getJobParameters().stream().filter(jp -> jp.getKey().equals("isScheduledJob")).findFirst().get().getValue());
+ assertEquals(person.getValueString("id"), scheduledJob.getJobParameters().stream().filter(jp -> jp.getKey().equals(TestUtils.TABLE_NAME_PERSON_MEMORY + "Id")).findFirst().get().getValue());
+ assertEquals(person.getValueString("id"), scheduledJob.getJobParameters().stream().filter(jp -> jp.getKey().equals("recordId")).findFirst().get().getValue());
+
+ /////////////////////////////////////////////////////////////////////////////////////////
+ // re-run - it should update the repeat seconds (per custom logic in test class below) //
+ /////////////////////////////////////////////////////////////////////////////////////////
+ new RunProcessAction().execute(input);
+ scheduledJobs = new QueryAction().execute(new QueryInput(ScheduledJob.TABLE_NAME).withIncludeAssociations(true)).getRecordEntities(ScheduledJob.class);
+ assertEquals(1, scheduledJobs.size());
+ scheduledJob = scheduledJobs.get(0);
+ assertEquals(61, scheduledJob.getRepeatSeconds());
+ }
+
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static class SyncPersonToScheduledJobProcess extends AbstractRecordSyncToScheduledJobProcess
+ {
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected ScheduledJob customizeScheduledJob(ScheduledJob scheduledJob, QRecord sourceRecord) throws QException
+ {
+ if(scheduledJob.getRepeatSeconds() != null)
+ {
+ ///////////////////////////////////
+ // increment by one on an update //
+ ///////////////////////////////////
+ return scheduledJob.withRepeatSeconds(scheduledJob.getRepeatSeconds() + 1);
+ }
+ else
+ {
+ return scheduledJob.withRepeatSeconds(60);
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected String getScheduledJobForeignKeyType()
+ {
+ return TestUtils.TABLE_NAME_PERSON_MEMORY;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected String getRecordForeignKeyFieldName()
+ {
+ return "id";
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected String getRecordForeignKeyPossibleValueSourceName()
+ {
+ return TestUtils.TABLE_NAME_PERSON_MEMORY;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected String getSourceTableName()
+ {
+ return TestUtils.TABLE_NAME_PERSON_MEMORY;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ protected String getProcessNameScheduledJobParameter()
+ {
+ return TestUtils.PROCESS_NAME_GREET_PEOPLE;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizerTest.java
new file mode 100644
index 00000000..5618b9a0
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizerTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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 com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob;
+import com.kingsrook.qqq.backend.core.utils.TestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*******************************************************************************
+ ** Unit test for BaseSyncToScheduledJobTableCustomizer
+ *******************************************************************************/
+class BaseSyncToScheduledJobTableCustomizerTest extends BaseTest
+{
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @BeforeEach
+ void beforeEach() throws QException
+ {
+ new AbstractRecordSyncToScheduledJobProcessTest().beforeEach();
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test() throws QException
+ {
+ QTableMetaData table = QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
+ BaseSyncToScheduledJobTableCustomizer.setTableCustomizers(table, new AbstractRecordSyncToScheduledJobProcessTest.SyncPersonToScheduledJobProcess());
+
+ QRecord person = new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecord(new QRecord().withValue("firstName", "Darin"))).getRecords().get(0);
+ assertEquals(1, QueryAction.execute(ScheduledJob.TABLE_NAME, null).size());
+
+ new DeleteAction().execute(new DeleteInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withPrimaryKey(person.getValue("id")));
+ assertEquals(0, QueryAction.execute(ScheduledJob.TABLE_NAME, null).size());
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMapTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMapTest.java
new file mode 100644
index 00000000..b74a9358
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMapTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.utils.collections;
+
+
+import java.math.BigDecimal;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+/*******************************************************************************
+ ** Unit test for TypeTolerantKeyMap
+ *******************************************************************************/
+class TypeTolerantKeyMapTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void test()
+ {
+ TypeTolerantKeyMap map = new TypeTolerantKeyMap<>(QFieldType.INTEGER);
+ map.put(1, new QRecord().withValue("id", 1));
+ map.put("2", new QRecord().withValue("id", 2));
+ map.put(3.0, new QRecord().withValue("id", 3));
+ map.put(new BigDecimal("4.00"), new QRecord().withValue("id", 4));
+
+ for(int i=1; i<=4; i++)
+ {
+ assertTrue(map.containsKey(i));
+ assertEquals(i, map.get(i).getValueInteger("id"));
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java
index e5511ed8..2bd74f71 100644
--- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java
+++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java
@@ -111,7 +111,7 @@ public abstract class AbstractRDBMSAction
**
** That is, table.backendDetails.tableName if set -- else, table.name
*******************************************************************************/
- protected String getTableName(QTableMetaData table)
+ public static String getTableName(QTableMetaData table)
{
if(table.getBackendDetails() instanceof RDBMSTableBackendDetails details)
{
@@ -130,7 +130,7 @@ public abstract class AbstractRDBMSAction
**
** That is, field.backendName if set -- else, field.name
*******************************************************************************/
- protected String getColumnName(QFieldMetaData field)
+ public static String getColumnName(QFieldMetaData field)
{
if(field.getBackendName() != null)
{
diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessor.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessor.java
new file mode 100644
index 00000000..34668e5e
--- /dev/null
+++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessor.java
@@ -0,0 +1,331 @@
+/*
+ * 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.module.rdbms.model.metadata;
+
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+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.instances.QInstanceEnricher;
+import com.kingsrook.qqq.backend.core.instances.assessment.QInstanceAssessor;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction;
+import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class RDBMSBackendAssessor
+{
+ private QInstanceAssessor assessor;
+ private RDBMSBackendMetaData backendMetaData;
+ private List tables;
+
+ private Map typeMap = new HashMap<>();
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public RDBMSBackendAssessor(QInstanceAssessor assessor, RDBMSBackendMetaData backendMetaData, List tables)
+ {
+ this.assessor = assessor;
+ this.backendMetaData = backendMetaData;
+ this.tables = tables;
+
+ ////////////////////////////////////////////////
+ // these are types as returned by mysql //
+ // let null in here mean unsupported QQQ type //
+ ////////////////////////////////////////////////
+ typeMap.put("TEXT", QFieldType.TEXT);
+ typeMap.put("BINARY", QFieldType.BLOB);
+ typeMap.put("SET", null);
+ typeMap.put("VARBINARY", QFieldType.BLOB);
+ typeMap.put("MEDIUMBLOB", QFieldType.BLOB);
+ typeMap.put("NUMERIC", QFieldType.INTEGER);
+ typeMap.put("BIGINT UNSIGNED", QFieldType.INTEGER);
+ typeMap.put("MEDIUMINT UNSIGNED", QFieldType.INTEGER);
+ typeMap.put("SMALLINT UNSIGNED", QFieldType.INTEGER);
+ typeMap.put("TINYINT UNSIGNED", QFieldType.INTEGER);
+ typeMap.put("BIT", null);
+ typeMap.put("FLOAT", null);
+ typeMap.put("REAL", null);
+ typeMap.put("VARCHAR", QFieldType.STRING);
+ typeMap.put("BOOL", QFieldType.BOOLEAN);
+ typeMap.put("YEAR", null);
+ typeMap.put("TIME", QFieldType.TIME);
+ typeMap.put("TIMESTAMP", QFieldType.DATE_TIME);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void assess()
+ {
+ try(Connection connection = new ConnectionManager().getConnection(backendMetaData))
+ {
+ ////////////////////////////////////////////////////////////////////
+ // read data type ids (integers) to names, for field-type mapping //
+ ////////////////////////////////////////////////////////////////////
+ DatabaseMetaData databaseMetaData;
+ Map dataTypeMap = new HashMap<>();
+ try
+ {
+ databaseMetaData = connection.getMetaData();
+ ResultSet typeInfoResultSet = databaseMetaData.getTypeInfo();
+ while(typeInfoResultSet.next())
+ {
+ String name = typeInfoResultSet.getString("TYPE_NAME");
+ Integer id = typeInfoResultSet.getInt("DATA_TYPE");
+ dataTypeMap.put(id, name);
+ }
+ }
+ catch(Exception e)
+ {
+ assessor.addError("Error loading metaData from RDBMS for backendName: " + backendMetaData.getName() + " - assessment cannot be completed.", e);
+ return;
+ }
+
+ ///////////////////////////////////////
+ // process each table in the backend //
+ ///////////////////////////////////////
+ for(QTableMetaData table : tables)
+ {
+ String tableName = AbstractRDBMSAction.getTableName(table);
+
+ try
+ {
+ ///////////////////////////////
+ // check if the table exists //
+ ///////////////////////////////
+ String databaseName = backendMetaData.getDatabaseName(); // these work for mysql - unclear about other vendors.
+ String schemaName = null;
+ try(ResultSet tableResultSet = databaseMetaData.getTables(databaseName, schemaName, tableName, null))
+ {
+ if(!tableResultSet.next())
+ {
+ assessor.addError("Table: " + table.getName() + " was not found in backend: " + backendMetaData.getName());
+ assessor.addSuggestion(suggestCreateTable(table));
+ continue;
+ }
+
+ //////////////////////////////
+ // read the table's columns //
+ //////////////////////////////
+ Map columnMap = new HashMap<>();
+ String primaryKeyColumnName = null;
+ try(ResultSet columnsResultSet = databaseMetaData.getColumns(databaseName, schemaName, tableName, null))
+ {
+ while(columnsResultSet.next())
+ {
+ String columnName = columnsResultSet.getString("COLUMN_NAME");
+ String columnSize = columnsResultSet.getString("COLUMN_SIZE");
+ Integer dataTypeId = columnsResultSet.getInt("DATA_TYPE");
+ String isNullable = columnsResultSet.getString("IS_NULLABLE");
+ String isAutoIncrement = columnsResultSet.getString("IS_AUTOINCREMENT");
+
+ String dataTypeName = dataTypeMap.get(dataTypeId);
+ QFieldMetaData columnMetaData = new QFieldMetaData(columnName, typeMap.get(dataTypeName));
+ columnMap.put(columnName, columnMetaData);
+
+ if("YES".equals(isAutoIncrement))
+ {
+ primaryKeyColumnName = columnName;
+ }
+ }
+ }
+
+ /////////////////////////////////
+ // diff the columns and fields //
+ /////////////////////////////////
+ for(QFieldMetaData column : columnMap.values())
+ {
+ boolean fieldExists = table.getFields().values().stream().anyMatch(f -> column.getName().equals(AbstractRDBMSAction.getColumnName(f)));
+ if(!fieldExists)
+ {
+ assessor.addWarning("Table: " + table.getName() + " has a column which was not found in the metaData: " + column.getName());
+ assessor.addSuggestion("// in QTableMetaData.withName(\"" + table.getName() + "\")\n"
+ + ".withField(new QFieldMetaData(\"" + column.getName() + "\", QFieldType." + column.getType() + ").withBackendName(\"" + column.getName() + "\")"); // todo - column_name to fieldName
+ }
+ }
+
+ for(QFieldMetaData field : table.getFields().values())
+ {
+ String columnName = AbstractRDBMSAction.getColumnName(field);
+ boolean columnExists = columnMap.values().stream().anyMatch(c -> c.getName().equals(columnName));
+ if(!columnExists)
+ {
+ assessor.addError("Table: " + table.getName() + " has a field which was not found in the database: " + field.getName());
+ assessor.addSuggestion("/* For table [" + tableName + "] in backend [" + table.getBackendName() + " (database " + databaseName + ")]: */\n"
+ + "ALTER TABLE " + tableName + " ADD " + QInstanceEnricher.inferBackendName(columnName) + " " + getDatabaseTypeForField(table, field) + ";");
+ }
+ }
+
+ ///////////////////////////////////////////////
+ // read unique constraints from the database //
+ ///////////////////////////////////////////////
+ Map> uniqueIndexMap = new HashMap<>();
+ try(ResultSet indexInfoResultSet = databaseMetaData.getIndexInfo(databaseName, schemaName, tableName, true, true))
+ {
+ while(indexInfoResultSet.next())
+ {
+ String indexName = indexInfoResultSet.getString("INDEX_NAME");
+ String columnName = indexInfoResultSet.getString("COLUMN_NAME");
+ uniqueIndexMap.computeIfAbsent(indexName, k -> new HashSet<>());
+ uniqueIndexMap.get(indexName).add(columnName);
+ }
+ }
+
+ //////////////////////////
+ // diff the unique keys //
+ //////////////////////////
+ for(UniqueKey uniqueKey : CollectionUtils.nonNullList(table.getUniqueKeys()))
+ {
+ Set fieldNames = uniqueKey.getFieldNames().stream().map(fieldName -> AbstractRDBMSAction.getColumnName(table.getField(fieldName))).collect(Collectors.toSet());
+ if(!uniqueIndexMap.containsValue(fieldNames))
+ {
+ assessor.addWarning("Table: " + table.getName() + " specifies a uniqueKey which was not found in the database: " + uniqueKey.getFieldNames());
+ assessor.addSuggestion("/* For table [" + tableName + "] in backend [" + table.getBackendName() + " (database " + databaseName + ")]: */\n"
+ + "ALTER TABLE " + tableName + " ADD UNIQUE (" + StringUtils.join(", ", fieldNames) + ");");
+ }
+ }
+
+ for(Set uniqueIndex : uniqueIndexMap.values())
+ {
+ //////////////////////////
+ // skip the primary key //
+ //////////////////////////
+ if(uniqueIndex.size() == 1 && uniqueIndex.contains(primaryKeyColumnName))
+ {
+ continue;
+ }
+
+ boolean foundInTableMetaData = false;
+ for(UniqueKey uniqueKey : CollectionUtils.nonNullList(table.getUniqueKeys()))
+ {
+ Set fieldNames = uniqueKey.getFieldNames().stream().map(fieldName -> AbstractRDBMSAction.getColumnName(table.getField(fieldName))).collect(Collectors.toSet());
+ if(uniqueIndex.equals(fieldNames))
+ {
+ foundInTableMetaData = true;
+ break;
+ }
+ }
+
+ if(!foundInTableMetaData)
+ {
+ assessor.addWarning("Table: " + table.getName() + " has a unique index which was not found in the metaData: " + uniqueIndex);
+ assessor.addSuggestion("// in QTableMetaData.withName(\"" + table.getName() + "\")\n"
+ + ".withUniqueKey(new UniqueKey(\"" + StringUtils.join("\", \"", uniqueIndex) + "\"))");
+ }
+ }
+
+ }
+ }
+ catch(Exception e)
+ {
+ assessor.addError("Error assessing table: " + table.getName() + " in backend: " + backendMetaData.getName(), e);
+ }
+ }
+ }
+ catch(Exception e)
+ {
+ assessor.addError("Error connecting to RDBMS for backendName: " + backendMetaData.getName(), e);
+ return;
+ }
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private String suggestCreateTable(QTableMetaData table)
+ {
+ StringBuilder rs = new StringBuilder("/* For table [" + table.getName() + "] in backend [" + table.getBackendName() + " (database " + (backendMetaData.getDatabaseName()) + ")]: */\n");
+ rs.append("CREATE TABLE ").append(AbstractRDBMSAction.getTableName(table)).append("\n");
+ rs.append("(\n");
+
+ List fields = new ArrayList<>();
+ for(QFieldMetaData field : table.getFields().values())
+ {
+ fields.add(" " + AbstractRDBMSAction.getColumnName(field) + " " + getDatabaseTypeForField(table, field));
+ }
+
+ rs.append(StringUtils.join(",\n", fields));
+
+ rs.append("\n);");
+ return (rs.toString());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private String getDatabaseTypeForField(QTableMetaData table, QFieldMetaData field)
+ {
+ return switch(field.getType())
+ {
+ case STRING ->
+ {
+ int n = Objects.requireNonNullElse(field.getMaxLength(), 250);
+ yield ("VARCHAR(" + n + ")");
+ }
+ case INTEGER ->
+ {
+ String suffix = table.getPrimaryKeyField().equals(field.getName()) ? " AUTO_INCREMENT PRIMARY KEY" : "";
+ yield ("INTEGER" + suffix);
+ }
+ case LONG ->
+ {
+ String suffix = table.getPrimaryKeyField().equals(field.getName()) ? " AUTO_INCREMENT PRIMARY KEY" : "";
+ yield ("BIGINT" + suffix);
+ }
+ case DECIMAL -> "DECIMAL(10,2)";
+ case BOOLEAN -> "BOOLEAN";
+ case DATE -> "DATE";
+ case TIME -> "TIME";
+ case DATE_TIME -> "TIMESTAMP";
+ case TEXT -> "TEXT";
+ case HTML -> "TEXT";
+ case PASSWORD -> "VARCHAR(40)";
+ case BLOB -> "BLOB";
+ };
+ }
+}
diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendMetaData.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendMetaData.java
index 277502ed..20796d2f 100644
--- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendMetaData.java
+++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendMetaData.java
@@ -22,12 +22,18 @@
package com.kingsrook.qqq.backend.module.rdbms.model.metadata;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
+import com.kingsrook.qqq.backend.core.instances.assessment.Assessable;
+import com.kingsrook.qqq.backend.core.instances.assessment.QInstanceAssessor;
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.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
import com.kingsrook.qqq.backend.module.rdbms.strategy.BaseRDBMSActionStrategy;
import com.kingsrook.qqq.backend.module.rdbms.strategy.RDBMSActionStrategyInterface;
@@ -36,7 +42,7 @@ import com.kingsrook.qqq.backend.module.rdbms.strategy.RDBMSActionStrategyInterf
/*******************************************************************************
** Meta-data to provide details of an RDBMS backend (e.g., connection params)
*******************************************************************************/
-public class RDBMSBackendMetaData extends QBackendMetaData
+public class RDBMSBackendMetaData extends QBackendMetaData implements Assessable
{
private String vendor;
private String hostName;
@@ -580,4 +586,25 @@ public class RDBMSBackendMetaData extends QBackendMetaData
}
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public void assess(QInstanceAssessor qInstanceAssessor, QInstance qInstance)
+ {
+ List tables = new ArrayList<>();
+ for(QTableMetaData table : qInstance.getTables().values())
+ {
+ if(Objects.equals(getName(), table.getBackendName()))
+ {
+ tables.add(table);
+ }
+ }
+
+ if(!tables.isEmpty())
+ {
+ new RDBMSBackendAssessor(qInstanceAssessor, this, tables).assess();
+ }
+ }
}
diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSTableMetaDataBuilder.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSTableMetaDataBuilder.java
index 7fc556d4..3537462c 100644
--- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSTableMetaDataBuilder.java
+++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSTableMetaDataBuilder.java
@@ -101,12 +101,6 @@ public class RDBMSTableMetaDataBuilder
String schemaName = null;
String tableNameForMetaDataQueries = tableName;
- if(backendMetaData.getVendor().equals("h2"))
- {
- databaseName = databaseName.toUpperCase();
- tableNameForMetaDataQueries = tableName.toUpperCase();
- }
-
try(ResultSet tableResultSet = databaseMetaData.getTables(databaseName, schemaName, tableNameForMetaDataQueries, null))
{
if(!tableResultSet.next())
diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java
index 19c909c2..518cd204 100644
--- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java
+++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java
@@ -153,12 +153,22 @@ public class TestUtils
*******************************************************************************/
public static RDBMSBackendMetaData defineBackend()
{
- return (new RDBMSBackendMetaData()
+ RDBMSBackendMetaData rdbmsBackendMetaData = new RDBMSBackendMetaData()
.withName(DEFAULT_BACKEND_NAME)
.withVendor("h2")
.withHostName("mem")
.withDatabaseName("test_database")
- .withUsername("sa"));
+ .withUsername("sa");
+
+ ////////////////////////////////////////////////////////////////////
+ // by default h2 up-shifts all names, which isn't how we expected //
+ // things to be, so, tell it not to do that. //
+ ////////////////////////////////////////////////////////////////////
+ String jdbcUrl = ConnectionManager.getJdbcUrl(rdbmsBackendMetaData);
+ jdbcUrl += ";DATABASE_TO_UPPER=FALSE";
+ rdbmsBackendMetaData.setJdbcUrl(jdbcUrl);
+
+ return rdbmsBackendMetaData;
}
diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManagerTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManagerTest.java
index 222734fa..73b3c64a 100644
--- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManagerTest.java
+++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/QueryManagerTest.java
@@ -438,8 +438,8 @@ class QueryManagerTest extends BaseTest
""");
List> rows = QueryManager.executeStatementForRows(connection, "SELECT * FROM test_table");
assertNotNull(rows);
- assertEquals(47, rows.get(0).get("INT_COL"));
- assertEquals("Q", rows.get(0).get("CHAR_COL"));
+ assertEquals(47, rows.get(0).get("int_col"));
+ assertEquals("Q", rows.get(0).get("char_col"));
}
}
diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessorTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessorTest.java
new file mode 100644
index 00000000..a8774814
--- /dev/null
+++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessorTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.module.rdbms.model.metadata;
+
+
+import java.sql.Connection;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.instances.assessment.QInstanceAssessor;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
+import com.kingsrook.qqq.backend.module.rdbms.BaseTest;
+import com.kingsrook.qqq.backend.module.rdbms.TestUtils;
+import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
+import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+
+/*******************************************************************************
+ ** Unit test for RDBMSBackendAssessor
+ *******************************************************************************/
+class RDBMSBackendAssessorTest extends BaseTest
+{
+ private static final QLogger LOG = QLogger.getLogger(RDBMSBackendAssessorTest.class);
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testSuccess() throws Exception
+ {
+ TestUtils.primeTestDatabase("prime-test-database.sql");
+ QInstanceAssessor assessor = new QInstanceAssessor(QContext.getQInstance());
+ assessor.assess();
+ System.out.println(assessor.getSummary());
+ assertEquals(0, assessor.getErrors().size());
+ assertEquals(0, assessor.getWarnings().size());
+ assertEquals(0, assessor.getExitCode());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testTableIssues() throws Exception
+ {
+ ///////////////////////////////////////////////////////////////////////////////
+ // start from primed database, but make a few alters to it and the meta-data //
+ ///////////////////////////////////////////////////////////////////////////////
+ TestUtils.primeTestDatabase("prime-test-database.sql");
+ ConnectionManager connectionManager = new ConnectionManager();
+ try(Connection connection = connectionManager.getConnection(TestUtils.defineBackend()))
+ {
+ QueryManager.executeUpdate(connection, "ALTER TABLE person ADD COLUMN suffix VARCHAR(20)");
+ QueryManager.executeUpdate(connection, "ALTER TABLE person ADD UNIQUE u_name (first_name, last_name)");
+ }
+
+ QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON)
+ .withField(new QFieldMetaData("middleName", QFieldType.STRING))
+ .withUniqueKey(new UniqueKey("firstName", "middleName", "lastName"));
+
+ ///////////////////////////
+ // un-prime the database //
+ ///////////////////////////
+ QInstanceAssessor assessor = new QInstanceAssessor(QContext.getQInstance());
+ assessor.assess();
+ LOG.info(assessor.getSummary());
+ assertNotEquals(0, assessor.getErrors().size());
+ assertNotEquals(0, assessor.getExitCode());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testTotalFailure() throws Exception
+ {
+ ///////////////////////////
+ // un-prime the database //
+ ///////////////////////////
+ TestUtils.primeTestDatabase("drop-test-database.sql");
+ QInstanceAssessor assessor = new QInstanceAssessor(QContext.getQInstance());
+ assessor.assess();
+ System.out.println(assessor.getSummary());
+ assertNotEquals(0, assessor.getErrors().size());
+ assertNotEquals(0, assessor.getExitCode());
+ }
+
+}
\ No newline at end of file
diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java
index 9dccf88d..c9f4fdb8 100644
--- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java
+++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java
@@ -122,6 +122,7 @@ public class SharingMetaDataProvider
qInstance.addTable(new QTableMetaData()
.withName(User.TABLE_NAME)
.withPrimaryKeyField("id")
+ .withBackendDetails(new RDBMSTableBackendDetails().withTableName("user"))
.withBackendName(TestUtils.DEFAULT_BACKEND_NAME)
.withFieldsFromEntity(User.class)
.withRecordSecurityLock(new RecordSecurityLock()
@@ -132,6 +133,7 @@ public class SharingMetaDataProvider
qInstance.addTable(new QTableMetaData()
.withName(Group.TABLE_NAME)
.withPrimaryKeyField("id")
+ .withBackendDetails(new RDBMSTableBackendDetails().withTableName("group"))
.withBackendName(TestUtils.DEFAULT_BACKEND_NAME)
.withFieldsFromEntity(Group.class));
QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Group.TABLE_NAME));
@@ -139,6 +141,7 @@ public class SharingMetaDataProvider
qInstance.addTable(new QTableMetaData()
.withName(Client.TABLE_NAME)
.withPrimaryKeyField("id")
+ .withBackendDetails(new RDBMSTableBackendDetails().withTableName("client"))
.withBackendName(TestUtils.DEFAULT_BACKEND_NAME)
.withFieldsFromEntity(Client.class));
QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Client.TABLE_NAME));
diff --git a/qqq-backend-module-rdbms/src/test/resources/drop-test-database.sql b/qqq-backend-module-rdbms/src/test/resources/drop-test-database.sql
new file mode 100644
index 00000000..0bd48d38
--- /dev/null
+++ b/qqq-backend-module-rdbms/src/test/resources/drop-test-database.sql
@@ -0,0 +1,32 @@
+--
+-- QQQ - Low-code Application Framework for Engineers.
+-- Copyright (C) 2021-2022. 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 .
+--
+
+DROP TABLE IF EXISTS person;
+DROP TABLE IF EXISTS personal_id_card;
+DROP TABLE IF EXISTS carrier;
+DROP TABLE IF EXISTS line_item_extrinsic;
+DROP TABLE IF EXISTS order_line;
+DROP TABLE IF EXISTS item;
+DROP TABLE IF EXISTS `order`;
+DROP TABLE IF EXISTS order_instructions;
+DROP TABLE IF EXISTS warehouse_store_int;
+DROP TABLE IF EXISTS store;
+DROP TABLE IF EXISTS warehouse;
diff --git a/qqq-dev-tools/pom.xml b/qqq-dev-tools/pom.xml
new file mode 100644
index 00000000..32acc4cf
--- /dev/null
+++ b/qqq-dev-tools/pom.xml
@@ -0,0 +1,50 @@
+
+
+ 4.0.0
+
+ com.kingsrook.qqq
+ qqq-dev-tools
+ 1.0.0-SNAPSHOT
+ jar
+
+ QQQ Dev Tools
+ Tools for developers of QQQ (is that the framework or applications or qbits or what?)
+
+
+ 17
+ 17
+ UTF-8
+ 5.10.0
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 17
+ 17
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.1.2
+
+
+
+
\ No newline at end of file
diff --git a/qqq-dev-tools/src/main/java/com/kingsrook/qqq/devtools/CreateNewQBit.java b/qqq-dev-tools/src/main/java/com/kingsrook/qqq/devtools/CreateNewQBit.java
new file mode 100644
index 00000000..e37e02df
--- /dev/null
+++ b/qqq-dev-tools/src/main/java/com/kingsrook/qqq/devtools/CreateNewQBit.java
@@ -0,0 +1,342 @@
+package com.kingsrook.qqq.devtools;
+
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.function.Consumer;
+
+
+/*******************************************************************************
+ ** todo picocli this project and class
+ *******************************************************************************/
+public class CreateNewQBit
+{
+ private String name;
+ private String root;
+
+ private static ExecutorService executorService = null;
+
+ private static String SED = "/opt/homebrew/bin/gsed"; // needs to be a version that supports -i (in-place edit)
+ private static String GIT = "/usr/bin/git";
+ private static String CP = "/bin/cp";
+ private static String MV = "/bin/mv";
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void main(String[] args)
+ {
+ args = new String[] { "/Users/dkelkhoff/git/kingsrook/qbits", "webhooks" };
+
+ if(args.length < 2)
+ {
+ System.out.println("Usage: java CreateNewQBit root-dir qbit-name");
+ System.exit(1);
+ }
+
+ CreateNewQBit instance = new CreateNewQBit();
+ instance.root = args[0];
+ instance.name = args[1];
+ System.exit(instance.run());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public int run()
+ {
+ try
+ {
+ String wordsName = makeWordsName(name);
+ wordsName = stripQBitPrefix(wordsName);
+ String dashName = makeDashName(wordsName);
+ String packageName = makePackageName(wordsName);
+ String className = makeClassName(wordsName);
+ String varName = makeVarName(wordsName);
+
+ if(!new File(root).exists())
+ {
+ System.err.println("ERROR: Root directory [" + root + "] does not exist.");
+ return (1);
+ }
+
+ File template = new File(root + File.separator + "TEMPLATE");
+ if(!template.exists())
+ {
+ System.err.println("ERROR: Template directory [TEMPLATE] does not exist under [" + root + "].");
+ return (1);
+ }
+
+ File dir = new File(root + File.separator + "qbit-" + dashName);
+ if(dir.exists())
+ {
+ System.err.println("ERROR: Directory [" + dashName + "] already exists under [" + root + "].");
+ return (1);
+ }
+
+ System.out.println("Creating qbit-" + dashName + ":");
+ System.out.printf("%13s %s\n", "packgaename:", packageName);
+ System.out.printf("%13s %s\n", "ClassName:", className);
+ System.out.printf("%13s %s\n", "varName:", varName);
+ System.out.println();
+
+ System.out.println("Copying template...");
+ ProcessResult cpResult = run(new ProcessBuilder(CP, "-rv", template.getAbsolutePath(), dir.getAbsolutePath()));
+ System.out.print(cpResult.stdout());
+ System.out.println();
+
+ System.out.println("Renaming files...");
+ renameFiles(dir, packageName, className);
+ System.out.println();
+
+ System.out.println("Updating file contents...");
+ replacePlaceholders(dir, dashName, packageName, className, varName);
+ System.out.println();
+
+ System.out.println("Init'ing git repo...");
+ run(new ProcessBuilder(GIT, "init").directory(dir));
+ System.out.println();
+
+ // git remote add origin https://github.com/Kingsrook/${name}.git ?
+ // echo https://app.circleci.com/projects/project-dashboard/github/Kingsrook after initial push
+ }
+ catch(Exception e)
+ {
+ e.printStackTrace();
+ return 1;
+ }
+ return 0;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ void renameFiles(File dir, String packageName, String className) throws Exception
+ {
+ String srcPath = dir.getAbsolutePath() + "/src/main/java/com/kingsrook/qbits";
+ String packagePath = packageName.replace('.', '/');
+ System.out.print(run(new ProcessBuilder(MV, "-v", srcPath + "/todo/TodoQBitConfig.java", srcPath + "/todo/" + className + "QBitConfig.java")).stdout());
+ System.out.print(run(new ProcessBuilder(MV, "-v", srcPath + "/todo/TodoQBitProducer.java", srcPath + "/todo/" + className + "QBitProducer.java")).stdout());
+ System.out.print(run(new ProcessBuilder(MV, "-v", srcPath + "/todo", srcPath + "/" + packagePath)).stdout());
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static void replacePlaceholders(File dir, String dashName, String packageName, String className, String varName) throws Exception
+ {
+ for(File file : dir.listFiles())
+ {
+ if(file.isDirectory())
+ {
+ replacePlaceholders(file, dashName, packageName, className, varName);
+ continue;
+ }
+
+ System.out.println("Replacing placeholders in: " + file.getAbsolutePath());
+ replaceOne("dashName", dashName, file);
+ replaceOne("packageName", packageName, file);
+ replaceOne("className", className, file);
+ replaceOne("varName", varName, file);
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static void replaceOne(String from, String to, File file) throws Exception
+ {
+ run(new ProcessBuilder(SED, "s/\\${" + from + "}/" + to + "/g", "-i", file.getAbsolutePath()));
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public record ProcessResult(Integer exitCode, String stdout, String stderr)
+ {
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ public boolean hasStdout()
+ {
+ return stdout != null && !stdout.isEmpty();
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ public boolean hasStderr()
+ {
+ return stderr != null && !stderr.isEmpty();
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public static ProcessResult run(ProcessBuilder builder) throws Exception
+ {
+ StringBuilder stdout = new StringBuilder();
+ StringBuilder stderr = new StringBuilder();
+
+ Process process = builder.start();
+ Future> stdoutFuture = getExecutorService().submit(new StreamGobbler(process.getInputStream(), stdout::append));
+ Future> stderrFuture = getExecutorService().submit(new StreamGobbler(process.getErrorStream(), stderr::append));
+
+ int exitCode = process.waitFor();
+ stdoutFuture.get();
+ stderrFuture.get();
+
+ return (new ProcessResult(exitCode, stdout.toString(), stderr.toString()));
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private static class StreamGobbler implements Runnable
+ {
+ private InputStream inputStream;
+ private Consumer consumer;
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public StreamGobbler(InputStream inputStream, Consumer consumer)
+ {
+ this.inputStream = inputStream;
+ this.consumer = consumer;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public void run()
+ {
+ new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(s -> consumer.accept(s + System.lineSeparator()));
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private static ExecutorService getExecutorService()
+ {
+ if(executorService == null)
+ {
+ executorService = Executors.newCachedThreadPool();
+ }
+ return (executorService);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private String makeWordsName(String s)
+ {
+ if(s.contains("-"))
+ {
+ return (s.toLowerCase().replace('-', ' '));
+ }
+
+ if(s.matches(".*[A-Z].*"))
+ {
+ return s.replaceAll("([A-Z])", "$1'").toLowerCase().trim();
+ }
+
+ return s;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static String stripQBitPrefix(String s)
+ {
+ return (s.replaceFirst("^qbit(s) ", ""));
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static String makeDashName(String s)
+ {
+ return (s.replace(' ', '-'));
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static String makePackageName(String s)
+ {
+ return (s.replace(" ", ""));
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static String makeClassName(String s)
+ {
+ StringBuilder rs = new StringBuilder();
+ String[] words = s.split(" ");
+ for(String word : words)
+ {
+ rs.append(word.substring(0, 1).toUpperCase());
+ if(word.length() > 1)
+ {
+ rs.append(word.substring(1));
+ }
+ }
+ return rs.toString();
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ static String makeVarName(String s)
+ {
+ String className = makeClassName(s);
+ return className.substring(0, 1).toLowerCase() + (className.length() == 1 ? "" : className.substring(1));
+ }
+}
diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/ApiInstanceMetaDataProvider.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/ApiInstanceMetaDataProvider.java
index d6874297..dbaf2d32 100644
--- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/ApiInstanceMetaDataProvider.java
+++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/model/metadata/ApiInstanceMetaDataProvider.java
@@ -56,6 +56,9 @@ public class ApiInstanceMetaDataProvider
public static final String TABLE_NAME_API_LOG = "apiLog";
public static final String TABLE_NAME_API_LOG_USER = "apiLogUser";
+ public static final String API_NAME_PVS_NAME = "apiName";
+ public static final String API_VERSION_PVS_NAME = "apiVersion";
+
/*******************************************************************************
@@ -142,7 +145,7 @@ public class ApiInstanceMetaDataProvider
}
instance.addPossibleValueSource(new QPossibleValueSource()
- .withName("apiName")
+ .withName(API_NAME_PVS_NAME)
.withType(QPossibleValueSourceType.ENUM)
.withEnumValues(apiNamePossibleValues));
@@ -152,7 +155,7 @@ public class ApiInstanceMetaDataProvider
}
instance.addPossibleValueSource(new QPossibleValueSource()
- .withName("apiVersion")
+ .withName(API_VERSION_PVS_NAME)
.withType(QPossibleValueSourceType.ENUM)
.withEnumValues(apiVersionPossibleValues));
}
diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinMetaData.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinMetaData.java
index 3c1ea8df..eda20c47 100644
--- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinMetaData.java
+++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinMetaData.java
@@ -25,8 +25,10 @@ package com.kingsrook.qqq.backend.javalin;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
+import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.middleware.javalin.metadata.JavalinRouteProviderMetaData;
import org.apache.logging.log4j.Level;
@@ -329,4 +331,16 @@ public class QJavalinMetaData implements QSupplementalInstanceMetaData
}
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public void validate(QInstance qInstance, QInstanceValidator validator)
+ {
+ for(JavalinRouteProviderMetaData routeProviderMetaData : CollectionUtils.nonNullList(routeProviders))
+ {
+ routeProviderMetaData.validate(qInstance, validator);
+ }
+ }
}
diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/metadata/JavalinRouteProviderMetaData.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/metadata/JavalinRouteProviderMetaData.java
index 82c09173..88f2b640 100644
--- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/metadata/JavalinRouteProviderMetaData.java
+++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/metadata/JavalinRouteProviderMetaData.java
@@ -23,8 +23,13 @@ package com.kingsrook.qqq.middleware.javalin.metadata;
import java.util.List;
+import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QMetaDataObject;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.middleware.javalin.routeproviders.authentication.RouteAuthenticatorInterface;
+import com.kingsrook.qqq.middleware.javalin.routeproviders.contexthandlers.RouteProviderContextHandlerInterface;
/*******************************************************************************
@@ -32,6 +37,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
*******************************************************************************/
public class JavalinRouteProviderMetaData implements QMetaDataObject
{
+ private String name;
private String hostedPath;
private String fileSystemPath;
@@ -40,6 +46,7 @@ public class JavalinRouteProviderMetaData implements QMetaDataObject
private List methods;
private QCodeReference routeAuthenticator;
+ private QCodeReference contextHandler;
@@ -206,4 +213,90 @@ public class JavalinRouteProviderMetaData implements QMetaDataObject
return (this);
}
+
+
+ /*******************************************************************************
+ ** Getter for contextHandler
+ *******************************************************************************/
+ public QCodeReference getContextHandler()
+ {
+ return (this.contextHandler);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for contextHandler
+ *******************************************************************************/
+ public void setContextHandler(QCodeReference contextHandler)
+ {
+ this.contextHandler = contextHandler;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for contextHandler
+ *******************************************************************************/
+ public JavalinRouteProviderMetaData withContextHandler(QCodeReference contextHandler)
+ {
+ this.contextHandler = contextHandler;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for name
+ *******************************************************************************/
+ public String getName()
+ {
+ return (this.name);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for name
+ *******************************************************************************/
+ public void setName(String name)
+ {
+ this.name = name;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for name
+ *******************************************************************************/
+ public JavalinRouteProviderMetaData withName(String name)
+ {
+ this.name = name;
+ return (this);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ public void validate(QInstance qInstance, QInstanceValidator validator)
+ {
+ String prefix = "In javalinRouteProvider '" + name + "', ";
+ if(StringUtils.hasContent(processName))
+ {
+ validator.assertCondition(qInstance.getProcesses().containsKey(processName), prefix + "unrecognized process name: " + processName + " in a javalinRouteProvider");
+ }
+
+ if(routeAuthenticator != null)
+ {
+ validator.validateSimpleCodeReference(prefix + "routeAuthenticator ", routeAuthenticator, RouteAuthenticatorInterface.class);
+ }
+
+ if(contextHandler != null)
+ {
+ validator.validateSimpleCodeReference(prefix + "contextHandler ", contextHandler, RouteProviderContextHandlerInterface.class);
+ }
+ }
+
}
diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouter.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouter.java
index 02dad90b..6ffbdbba 100644
--- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouter.java
+++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouter.java
@@ -22,34 +22,27 @@
package com.kingsrook.qqq.middleware.javalin.routeproviders;
-import java.io.InputStream;
-import java.io.Serializable;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
-import com.kingsrook.qqq.backend.core.actions.tables.StorageAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
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.storage.StorageInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
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 com.kingsrook.qqq.backend.javalin.QJavalinImplementation;
import com.kingsrook.qqq.backend.javalin.QJavalinUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import com.kingsrook.qqq.middleware.javalin.metadata.JavalinRouteProviderMetaData;
import com.kingsrook.qqq.middleware.javalin.routeproviders.authentication.RouteAuthenticatorInterface;
+import com.kingsrook.qqq.middleware.javalin.routeproviders.contexthandlers.DefaultRouteProviderContextHandler;
+import com.kingsrook.qqq.middleware.javalin.routeproviders.contexthandlers.RouteProviderContextHandlerInterface;
import io.javalin.apibuilder.ApiBuilder;
import io.javalin.apibuilder.EndpointGroup;
import io.javalin.http.Context;
-import io.javalin.http.HttpStatus;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -65,6 +58,7 @@ public class ProcessBasedRouter implements QJavalinRouteProviderInterface
private final List methods;
private QCodeReference routeAuthenticator;
+ private QCodeReference contextHandler;
private QInstance qInstance;
@@ -88,6 +82,7 @@ public class ProcessBasedRouter implements QJavalinRouteProviderInterface
{
this(routeProvider.getHostedPath(), routeProvider.getProcessName(), routeProvider.getMethods());
setRouteAuthenticator(routeProvider.getRouteAuthenticator());
+ setContextHandler(routeProvider.getContextHandler());
}
@@ -188,72 +183,27 @@ public class ProcessBasedRouter implements QJavalinRouteProviderInterface
{
LOG.info("Running process to serve route", logPair("processName", processName), logPair("path", context.path()));
+ //////////////////////////////////////////////////////////////////////////////////////
+ // handle request (either using route's specific context handler, or a default one) //
+ //////////////////////////////////////////////////////////////////////////////////////
+ RouteProviderContextHandlerInterface contextHandler = createContextHandler();
+ contextHandler.handleRequest(context, input);
+
+ // todo - make the inputStream available to the process to stream results?
+ // maybe via the callback object??? input.setCallback(new QProcessCallback() {});
+ // context.resultInputStream();
+
/////////////////////
// run the process //
/////////////////////
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
- input.addValue("path", context.path());
- input.addValue("method", context.method());
- input.addValue("pathParams", new HashMap<>(context.pathParamMap()));
- input.addValue("queryParams", new HashMap<>(context.queryParamMap()));
- input.addValue("formParams", new HashMap<>(context.formParamMap()));
- input.addValue("cookies", new HashMap<>(context.cookieMap()));
- input.addValue("requestHeaders", new HashMap<>(context.headerMap()));
-
RunProcessOutput runProcessOutput = new RunProcessAction().execute(input);
- /////////////////
- // headers map //
- /////////////////
- Serializable headers = runProcessOutput.getValue("responseHeaders");
- if(headers instanceof Map headersMap)
+ /////////////////////
+ // handle response //
+ /////////////////////
+ if(contextHandler.handleResponse(context, runProcessOutput))
{
- for(Object key : headersMap.keySet())
- {
- context.header(ValueUtils.getValueAsString(key), ValueUtils.getValueAsString(headersMap.get(key)));
- }
- }
-
- // todo - make the inputStream available to the process
- // maybe via the callback object??? input.setCallback(new QProcessCallback() {});
- // context.resultInputStream();
-
- //////////////
- // response //
- //////////////
- Integer statusCode = runProcessOutput.getValueInteger("statusCode");
- String redirectURL = runProcessOutput.getValueString("redirectURL");
- String responseString = runProcessOutput.getValueString("responseString");
- byte[] responseBytes = runProcessOutput.getValueByteArray("responseBytes");
- StorageInput responseStorageInput = (StorageInput) runProcessOutput.getValue("responseStorageInput");
-
- if(StringUtils.hasContent(redirectURL))
- {
- context.redirect(redirectURL, statusCode == null ? HttpStatus.FOUND : HttpStatus.forStatus(statusCode));
- return;
- }
-
- if(statusCode != null)
- {
- context.status(statusCode);
- }
-
- if(StringUtils.hasContent(responseString))
- {
- context.result(responseString);
- return;
- }
-
- if(responseBytes != null && responseBytes.length > 0)
- {
- context.result(responseBytes);
- return;
- }
-
- if(responseStorageInput != null)
- {
- InputStream inputStream = new StorageAction().getInputStream(responseStorageInput);
- context.result(inputStream);
return;
}
@@ -271,6 +221,23 @@ public class ProcessBasedRouter implements QJavalinRouteProviderInterface
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ private RouteProviderContextHandlerInterface createContextHandler()
+ {
+ if(contextHandler != null)
+ {
+ return QCodeLoader.getAdHoc(RouteProviderContextHandlerInterface.class, this.contextHandler);
+ }
+ else
+ {
+ return (new DefaultRouteProviderContextHandler());
+ }
+ }
+
+
+
/*******************************************************************************
** Getter for routeAuthenticator
*******************************************************************************/
@@ -300,4 +267,35 @@ public class ProcessBasedRouter implements QJavalinRouteProviderInterface
return (this);
}
+
+ /*******************************************************************************
+ ** Getter for contextHandler
+ *******************************************************************************/
+ public QCodeReference getContextHandler()
+ {
+ return (this.contextHandler);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for contextHandler
+ *******************************************************************************/
+ public void setContextHandler(QCodeReference contextHandler)
+ {
+ this.contextHandler = contextHandler;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for contextHandler
+ *******************************************************************************/
+ public ProcessBasedRouter withContextHandler(QCodeReference contextHandler)
+ {
+ this.contextHandler = contextHandler;
+ return (this);
+ }
+
+
}
diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouterPayload.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouterPayload.java
index a064a7db..08e3dd53 100644
--- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouterPayload.java
+++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/ProcessBasedRouterPayload.java
@@ -42,6 +42,7 @@ public class ProcessBasedRouterPayload extends QProcessPayload
private Map> queryParams;
private Map> formParams;
private Map cookies;
+ private String bodyString;
private Integer statusCode;
private String redirectURL;
@@ -451,4 +452,35 @@ public class ProcessBasedRouterPayload extends QProcessPayload
return (this);
}
+
+ /*******************************************************************************
+ ** Getter for bodyString
+ *******************************************************************************/
+ public String getBodyString()
+ {
+ return (this.bodyString);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for bodyString
+ *******************************************************************************/
+ public void setBodyString(String bodyString)
+ {
+ this.bodyString = bodyString;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for bodyString
+ *******************************************************************************/
+ public ProcessBasedRouterPayload withBodyString(String bodyString)
+ {
+ this.bodyString = bodyString;
+ return (this);
+ }
+
+
}
diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/DefaultRouteProviderContextHandler.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/DefaultRouteProviderContextHandler.java
new file mode 100644
index 00000000..efe4facd
--- /dev/null
+++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/DefaultRouteProviderContextHandler.java
@@ -0,0 +1,159 @@
+/*
+ * 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.middleware.javalin.routeproviders.contexthandlers;
+
+
+import java.io.InputStream;
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Map;
+import com.kingsrook.qqq.backend.core.actions.tables.StorageAction;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+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.storage.StorageInput;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import io.javalin.http.Context;
+import io.javalin.http.HttpStatus;
+
+
+/*******************************************************************************
+ ** default implementation of this interface. reads the request body as a string
+ *******************************************************************************/
+public class DefaultRouteProviderContextHandler implements RouteProviderContextHandlerInterface
+{
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public void handleRequest(Context context, RunProcessInput input)
+ {
+ input.addValue("path", context.path());
+ input.addValue("method", context.method());
+ input.addValue("pathParams", new HashMap<>(context.pathParamMap()));
+ input.addValue("queryParams", new HashMap<>(context.queryParamMap()));
+ input.addValue("cookies", new HashMap<>(context.cookieMap()));
+ input.addValue("requestHeaders", new HashMap<>(context.headerMap()));
+
+ handleRequestBody(context, input);
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ protected void handleRequestBody(Context context, RunProcessInput input)
+ {
+ input.addValue("formParams", new HashMap<>(context.formParamMap()));
+ input.addValue("bodyString", context.body());
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ @Override
+ public boolean handleResponse(Context context, RunProcessOutput runProcessOutput) throws QException
+ {
+ handleResponseHeaders(context, runProcessOutput);
+
+ //////////////
+ // response //
+ //////////////
+ Integer statusCode = runProcessOutput.getValueInteger("statusCode");
+ String redirectURL = runProcessOutput.getValueString("redirectURL");
+
+ if(StringUtils.hasContent(redirectURL))
+ {
+ context.redirect(redirectURL, statusCode == null ? HttpStatus.FOUND : HttpStatus.forStatus(statusCode));
+ return true;
+ }
+
+ if(statusCode != null)
+ {
+ context.status(statusCode);
+ }
+
+ if(handleResponseBody(context, runProcessOutput))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ protected void handleResponseHeaders(Context context, RunProcessOutput runProcessOutput)
+ {
+ /////////////////
+ // headers map //
+ /////////////////
+ Serializable headers = runProcessOutput.getValue("responseHeaders");
+ if(headers instanceof Map headersMap)
+ {
+ for(Object key : headersMap.keySet())
+ {
+ context.header(ValueUtils.getValueAsString(key), ValueUtils.getValueAsString(headersMap.get(key)));
+ }
+ }
+ }
+
+
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ protected boolean handleResponseBody(Context context, RunProcessOutput runProcessOutput) throws QException
+ {
+ String responseString = runProcessOutput.getValueString("responseString");
+ byte[] responseBytes = runProcessOutput.getValueByteArray("responseBytes");
+ StorageInput responseStorageInput = (StorageInput) runProcessOutput.getValue("responseStorageInput");
+ if(StringUtils.hasContent(responseString))
+ {
+ context.result(responseString);
+ return true;
+ }
+
+ if(responseBytes != null && responseBytes.length > 0)
+ {
+ context.result(responseBytes);
+ return true;
+ }
+
+ if(responseStorageInput != null)
+ {
+ InputStream inputStream = new StorageAction().getInputStream(responseStorageInput);
+ context.result(inputStream);
+ return true;
+ }
+ return false;
+ }
+
+}
diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/RouteProviderContextHandlerInterface.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/RouteProviderContextHandlerInterface.java
new file mode 100644
index 00000000..aaa9d490
--- /dev/null
+++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/RouteProviderContextHandlerInterface.java
@@ -0,0 +1,49 @@
+/*
+ * 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.middleware.javalin.routeproviders.contexthandlers;
+
+
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
+import io.javalin.http.Context;
+
+
+/*******************************************************************************
+ ** interface for how to handle the javalin context for a process based route provider.
+ ** e.g., taking things like query params and the request body into the process input
+ ** and similarly for the http response from the process output..
+ *******************************************************************************/
+public interface RouteProviderContextHandlerInterface
+{
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ void handleRequest(Context context, RunProcessInput runProcessInput);
+
+ /***************************************************************************
+ **
+ ***************************************************************************/
+ boolean handleResponse(Context context, RunProcessOutput runProcessOutput) throws QException;
+
+}