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