From ffca465f04f638f5019cec296ea7671dff014163 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 5 Jun 2025 10:59:32 -0500 Subject: [PATCH 01/35] Add option to specify Comparator, for custom sorting of options [skip ci] --- ...onePossibleValueSourceMetaDataProvider.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java index fa06e309..33d21c04 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/common/TimeZonePossibleValueSourceMetaDataProvider.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.common; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.TimeZone; import java.util.function.Function; @@ -47,7 +48,7 @@ public class TimeZonePossibleValueSourceMetaDataProvider *******************************************************************************/ public QPossibleValueSource produce() { - return (produce(null, null)); + return (produce(null, null, null)); } @@ -56,6 +57,16 @@ public class TimeZonePossibleValueSourceMetaDataProvider ** *******************************************************************************/ public QPossibleValueSource produce(Predicate filter, Function labelMapper) + { + return (produce(filter, labelMapper, null)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public QPossibleValueSource produce(Predicate filter, Function labelMapper, Comparator> comparator) { QPossibleValueSource possibleValueSource = new QPossibleValueSource() .withName("timeZones") @@ -72,6 +83,11 @@ public class TimeZonePossibleValueSourceMetaDataProvider } } + if(comparator != null) + { + enumValues.sort(comparator); + } + possibleValueSource.withEnumValues(enumValues); return (possibleValueSource); } From 5db8cf9ca1b0f88aeb525ed240225c67e9c60902 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 15:11:14 -0500 Subject: [PATCH 02/35] Initial checkin of process & table customizer to help sync scheduled jobs for records in a table --- ...stractRecordSyncToScheduledJobProcess.java | 245 +++++++++++++ ...BaseSyncToScheduledJobTableCustomizer.java | 337 ++++++++++++++++++ ...ctRecordSyncToScheduledJobProcessTest.java | 193 ++++++++++ ...SyncToScheduledJobTableCustomizerTest.java | 74 ++++ 4 files changed, 849 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcess.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizer.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcessTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizerTest.java 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..aa4a2c46 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/AbstractRecordSyncToScheduledJobProcess.java @@ -0,0 +1,245 @@ +/* + * Copyright © 2022-2024. ColdTrack . All Rights Reserved. + */ + +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..340e376a --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/processes/BaseSyncToScheduledJobTableCustomizer.java @@ -0,0 +1,337 @@ +/* + * 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."); + } + + 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."); + } + + 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/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 From 3c765e9e474cdc5c7d787f4c9a3484e6da8c3712 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 15:19:10 -0500 Subject: [PATCH 03/35] Add support for one-to-one joins; Add support for tables that aren't yet in the QInstance, but instead is in the QBitProductionContext's metadataProducerMultiOutputStack --- ...omRecordEntityGenericMetaDataProducer.java | 57 ++++++++++++++----- .../producers/annotations/ChildJoin.java | 2 + 2 files changed, 46 insertions(+), 13 deletions(-) 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/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; + /*************************************************************************** ** ***************************************************************************/ From 60c5c11549ec35eb278f078230dc30f0b20e9c3f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 15:20:38 -0500 Subject: [PATCH 04/35] Add support for one-to-one joins; Add support for tables that aren't yet in the QInstance, but instead is in the QBitProductionContext's metadataProducerMultiOutputStack --- ...omRecordEntityGenericMetaDataProducer.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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()) From 7089ec92a63c893cb5b44a15198c12e2de2dbec9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:03:35 -0500 Subject: [PATCH 05/35] Add instance-level pre- and post- insert and update table customizers --- .../core/actions/tables/InsertAction.java | 52 +++++- .../core/actions/tables/UpdateAction.java | 63 +++++++- .../core/instances/QInstanceValidator.java | 26 ++- .../core/model/metadata/QInstance.java | 77 +++++++++ ...tionInstanceLevelTableCustomizersTest.java | 149 ++++++++++++++++++ ...tionInstanceLevelTableCustomizersTest.java | 70 ++++++++ .../instances/QInstanceValidatorTest.java | 24 +++ 7 files changed, 449 insertions(+), 12 deletions(-) create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionInstanceLevelTableCustomizersTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateActionInstanceLevelTableCustomizersTest.java 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..8730ae14 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); } 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 8f7793ef..89af643f 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.RecordAutomationHandler; +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; @@ -252,6 +253,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); + } + } + } } @@ -301,6 +313,17 @@ public class QInstanceValidator validatorPlugins.clear(); } + + + /******************************************************************************* + ** Getter for validatorPlugins + ** + *******************************************************************************/ + public static ListingHash, QInstanceValidatorPluginInterface> getValidatorPlugins() + { + return validatorPlugins; + } + /******************************************************************************* @@ -2238,8 +2261,7 @@ public class QInstanceValidator /******************************************************************************* ** *******************************************************************************/ - @SafeVarargs - private void validateSimpleCodeReference(String prefix, QCodeReference codeReference, Class... anyOfExpectedClasses) + public void validateSimpleCodeReference(String prefix, QCodeReference codeReference, Class... anyOfExpectedClasses) { if(!preAssertionsForCodeReference(codeReference, prefix)) { 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/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..b911c7da --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionInstanceLevelTableCustomizersTest.java @@ -0,0 +1,149 @@ +/* + * 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.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 +{ + + + /******************************************************************************* + ** + *******************************************************************************/ + @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/instances/QInstanceValidatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java index d790ca59..7f651074 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. ** From ff4a0b88496807a74924cf166e95dc77b93afd48 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:03:48 -0500 Subject: [PATCH 06/35] Initial checkin --- .../utils/collections/TypeTolerantKeyMap.java | 76 +++++++++++++++++++ .../collections/TypeTolerantKeyMapTest.java | 59 ++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMap.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/TypeTolerantKeyMapTest.java 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/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 From e9e029d8e99096d0717ab36a45e3de8815f23582 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:04:11 -0500 Subject: [PATCH 07/35] Add setRecordLinksToRecordsFromTableDynamicForPostQuery --- .../model/tables/QQQTableTableManager.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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); + } + } From 2ee26b14a932c85e685080c82e6c2b378e6db10e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:04:48 -0500 Subject: [PATCH 08/35] Add a null check for table fields (since instance isn't validated yet) --- .../kingsrook/qqq/backend/core/instances/QInstanceEnricher.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6895a258..ec32d57d 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 @@ -1404,7 +1404,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 = table.getFields().get(primaryKeyField); From 4883514f583b7c87c918af6781a5ae289cc4e46d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:18:17 -0500 Subject: [PATCH 09/35] Add getDefaultBackendNameForTables --- .../backend/core/model/metadata/qbits/QBitConfig.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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); + } + } From cc19268132a53ae6990ae544babbf5840aa38092 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:46:07 -0500 Subject: [PATCH 10/35] New version of interface for QBitMetaData production --- .../metadata/qbits/QBitMetaDataProducer.java | 192 ++++++++++++++++++ .../model/metadata/qbits/QBitProducer.java | 3 - .../metadata/qbits/QBitProductionContext.java | 136 +++++++++++++ 3 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitMetaDataProducer.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/qbits/QBitProductionContext.java 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 7fef2174..4069c14d 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 QBitComponentMetaDataProducer) 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(); + } + +} From 12383930b05347142be5d4bcbb84194334462c80 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:47:50 -0500 Subject: [PATCH 11/35] Try to make sure values that this backend stores are of the appropriate field types. --- .../memory/MemoryRecordStore.java | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) 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 b53d69a2..4e6486b6 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 @@ -74,6 +74,7 @@ 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 com.kingsrook.qqq.backend.core.utils.ValueUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -364,6 +365,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); @@ -414,6 +418,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)); + } + } + } + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -444,7 +472,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) From e11a23ccc0570346ac5cf4e6c70a137d0d67716a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:48:47 -0500 Subject: [PATCH 12/35] Make sortMetaDataProducers a public method (qbit producer can use it); add childJoin().isOneToOne --- .../model/metadata/MetaDataProducerHelper.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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; } From 97883b3e4308f063a423e2ca3f456f7670f561d6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:52:20 -0500 Subject: [PATCH 13/35] Initial checkin --- .../actions/customizers/OldRecordHelper.java | 88 +++++++++++++++++++ .../customizers/OldRecordHelperTest.java | 70 +++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelper.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/OldRecordHelperTest.java 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/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 From 3d6f05e4ea90e115f5e0f4d9ebb9b137f848b0a7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 16:52:35 -0500 Subject: [PATCH 14/35] avoid NPE on empty contennts --- .../core/model/metadata/MetaDataProducerMultiOutput.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 685e747a91cb8cc526d7a7b47df68948897f8894 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 17:02:54 -0500 Subject: [PATCH 15/35] Add log method --- .../ProcessSummaryLineInterface.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 From a7b5e00e279302504443c8bc54c0103949df20b9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 17:03:46 -0500 Subject: [PATCH 16/35] Make constants out of API_NAME_PVS_NAME and API_VERSION_PVS_NAME --- .../api/model/metadata/ApiInstanceMetaDataProvider.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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)); } From 1808cea5c0a6675db3aa76646ea5829d92101910 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 20:28:42 -0500 Subject: [PATCH 17/35] Update processBasedRouters to use different handlers for processing the javalin context - with a new default implementation that makes available the request body as a string --- .../qqq/backend/javalin/QJavalinMetaData.java | 14 ++ .../JavalinRouteProviderMetaData.java | 93 ++++++++++ .../routeproviders/ProcessBasedRouter.java | 134 ++++++++------- .../ProcessBasedRouterPayload.java | 32 ++++ .../DefaultRouteProviderContextHandler.java | 159 ++++++++++++++++++ .../RouteProviderContextHandlerInterface.java | 49 ++++++ 6 files changed, 413 insertions(+), 68 deletions(-) create mode 100644 qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/DefaultRouteProviderContextHandler.java create mode 100644 qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/routeproviders/contexthandlers/RouteProviderContextHandlerInterface.java 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 8d754469..d0ae1fd7 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 @@ -41,6 +41,7 @@ public class ProcessBasedRouterPayload extends QProcessPayload private Map> queryParams; private Map> formParams; private Map cookies; + private String bodyString; private Integer statusCode; private String redirectURL; @@ -410,4 +411,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; + +} From efc69fee4b7ccd24402e9538f1987ca3bf69bbeb Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 20:29:07 -0500 Subject: [PATCH 18/35] Initial checkin --- qqq-dev-tools/pom.xml | 50 +++ .../kingsrook/qqq/devtools/CreateNewQBit.java | 339 ++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 qqq-dev-tools/pom.xml create mode 100644 qqq-dev-tools/src/main/java/com/kingsrook/qqq/devtools/CreateNewQBit.java 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)); + } +} From da52fccc8651ff2202dd73ae03778fdb13280f9a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 20:31:00 -0500 Subject: [PATCH 19/35] Initial version of QInstanceAssessor - to compare rdbms based meta-data to the actual database. --- .../core/instances/assessment/Assessable.java | 37 ++ .../assessment/QInstanceAssessor.java | 215 ++++++++++++ .../rdbms/actions/AbstractRDBMSAction.java | 4 +- .../model/metadata/RDBMSBackendAssessor.java | 331 ++++++++++++++++++ .../model/metadata/RDBMSBackendMetaData.java | 29 +- 5 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/Assessable.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/assessment/QInstanceAssessor.java create mode 100644 qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessor.java 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-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..720fb8cf --- /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(); + } + } } From 6efc34b69e9716c84ace9e8a0c937d375d857cc2 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 12 Jun 2025 20:33:10 -0500 Subject: [PATCH 20/35] Checkstyle --- ...stractRecordSyncToScheduledJobProcess.java | 19 ++++++++++++++++++- .../instances/QInstanceValidatorTest.java | 10 +++++----- .../model/metadata/RDBMSBackendAssessor.java | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) 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 index aa4a2c46..65ed317c 100644 --- 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 @@ -1,5 +1,22 @@ /* - * Copyright © 2022-2024. ColdTrack . All Rights Reserved. + * 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; 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 7f651074..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 @@ -186,11 +186,11 @@ public class QInstanceValidatorTest extends BaseTest "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)); - }, + { + 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"); 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 index 720fb8cf..34668e5e 100644 --- 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 @@ -164,7 +164,7 @@ public class RDBMSBackendAssessor QFieldMetaData columnMetaData = new QFieldMetaData(columnName, typeMap.get(dataTypeName)); columnMap.put(columnName, columnMetaData); - if("YES" .equals(isAutoIncrement)) + if("YES".equals(isAutoIncrement)) { primaryKeyColumnName = columnName; } From 4827669c0a009b09fb17b7c9ded1a5be78ca84b2 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 08:02:04 -0500 Subject: [PATCH 21/35] Add missing 'extends BaseTest' --- .../tables/InsertActionInstanceLevelTableCustomizersTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index b911c7da..4f81c0f1 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -44,7 +45,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; /******************************************************************************* ** *******************************************************************************/ -public class InsertActionInstanceLevelTableCustomizersTest +public class InsertActionInstanceLevelTableCustomizersTest extends BaseTest { From 962d09b120f1714e9867d7a96a0ade699921e78a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 08:43:16 -0500 Subject: [PATCH 22/35] Add basic test for RDBMS Assessor; change h2 to not upshift all names (and backout some places where we'd previously worked around that) --- .../metadata/RDBMSTableMetaDataBuilder.java | 6 -- .../qqq/backend/module/rdbms/TestUtils.java | 14 +++- .../module/rdbms/jdbc/QueryManagerTest.java | 4 +- .../metadata/RDBMSBackendAssessorTest.java | 73 +++++++++++++++++++ .../sharing/SharingMetaDataProvider.java | 3 + 5 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessorTest.java 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..748e552d --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/model/metadata/RDBMSBackendAssessorTest.java @@ -0,0 +1,73 @@ +/* + * 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.TestUtils; +import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSActionTest; +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 RDBMSActionTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @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 + { + ////////////////////////////// + // don't prime the database // + ////////////////////////////// + // TestUtils.primeTestDatabase("prime-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)); From d23dbac0d992d337c6510dc675eb27c05a5265b5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 09:00:48 -0500 Subject: [PATCH 23/35] Fix assessor test that expects empty database --- .../metadata/RDBMSBackendAssessorTest.java | 13 ++++---- .../src/test/resources/drop-test-database.sql | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 qqq-backend-module-rdbms/src/test/resources/drop-test-database.sql 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 index 748e552d..75afb935 100644 --- 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 @@ -24,8 +24,8 @@ 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 com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSActionTest; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -34,7 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; /******************************************************************************* ** Unit test for RDBMSBackendAssessor *******************************************************************************/ -class RDBMSBackendAssessorTest extends RDBMSActionTest +class RDBMSBackendAssessorTest extends BaseTest { /******************************************************************************* @@ -53,16 +53,17 @@ class RDBMSBackendAssessorTest extends RDBMSActionTest } + /******************************************************************************* ** *******************************************************************************/ @Test void testIssues() throws Exception { - ////////////////////////////// - // don't prime the database // - ////////////////////////////// - // TestUtils.primeTestDatabase("prime-test-database.sql"); + /////////////////////////// + // un-prime the database // + /////////////////////////// + TestUtils.primeTestDatabase("drop-test-database.sql"); QInstanceAssessor assessor = new QInstanceAssessor(QContext.getQInstance()); assessor.assess(); assessor.printSummary(); 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; From 55905d251d079709511bfb682ceb7015c5f7404f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 11:50:21 -0500 Subject: [PATCH 24/35] Better clone methods --- .../security/MultiRecordSecurityLock.java | 2 +- .../metadata/security/RecordSecurityLock.java | 29 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) 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)); + } } From 786f9ba8df3b46272ec3b6358a4de0a5d75ac9ae Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 11:52:13 -0500 Subject: [PATCH 25/35] Add method `allowedToReadRecord`. update some methods in here to take session as parameter; --- .../core/actions/tables/UpdateAction.java | 2 +- .../ValidateRecordSecurityLockHelper.java | 40 +++++++++++++++---- .../ValidateRecordSecurityLockHelperTest.java | 33 +++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) 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 8730ae14..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 @@ -456,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/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 From 1fb509fea1cef7a2a070f9ade6de4c2becf4230f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 15:36:31 -0500 Subject: [PATCH 26/35] Prevent multiple copies of enrichment & validation plugins; actually implement QSupplementalInstanceMetaData enrichment --- .../core/instances/QInstanceEnricher.java | 19 ++++++++++++++++++- .../core/instances/QInstanceValidator.java | 15 +++++++++++++-- .../QInstanceEnricherPluginInterface.java | 9 +++++++++ .../QInstanceValidatorPluginInterface.java | 9 +++++++++ .../QSupplementalInstanceMetaData.java | 3 +-- 5 files changed, 50 insertions(+), 5 deletions(-) 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 ec32d57d..84046631 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; @@ -210,6 +211,11 @@ public class QInstanceEnricher ***************************************************************************/ private void enrichInstance() { + for(QSupplementalInstanceMetaData supplementalInstanceMetaData : qInstance.getSupplementalMetaData().values()) + { + supplementalInstanceMetaData.enrich(qInstance); + } + runPlugins(QInstance.class, qInstance, qInstance); } @@ -1477,7 +1483,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 89af643f..0ec763f1 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 @@ -295,7 +295,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 { @@ -313,7 +324,7 @@ public class QInstanceValidator validatorPlugins.clear(); } - + /******************************************************************************* ** Getter for validatorPlugins 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/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 // From 7b190d810aa41ce26208d02af747dc914319ef24 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 15:39:12 -0500 Subject: [PATCH 27/35] Actually return (don't just log) if no scheduledJobs table in instance --- .../processes/BaseSyncToScheduledJobTableCustomizer.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 340e376a..aebd82c1 100644 --- 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 @@ -164,6 +164,7 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt 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(); @@ -205,6 +206,7 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt if(QContext.getQInstance().getTable(ScheduledJob.TABLE_NAME) == null) { LOG.info("ScheduledJob table not found, skipping scheduled job delete."); + return; } List sourceRecordIds = records.stream() From 55e372a70fd57c1579c1ae3424eab23771627082 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 13 Jun 2025 20:07:48 -0500 Subject: [PATCH 28/35] Increase rdbms assessor coverage; decrease its usage of stdout --- .../assessment/QInstanceAssessor.java | 20 +++++--- .../metadata/RDBMSBackendAssessorTest.java | 49 +++++++++++++++++-- 2 files changed, 58 insertions(+), 11 deletions(-) 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 index 08b25dfa..b1b9b2f3 100644 --- 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 @@ -77,22 +77,24 @@ public class QInstanceAssessor ** *******************************************************************************/ @SuppressWarnings("checkstyle:AvoidEscapedUnicodeCharacters") - public void printSummary() + public String getSummary() { + StringBuilder rs = new StringBuilder(); + /////////////////////////// // print header & errors // /////////////////////////// if(CollectionUtils.nullSafeIsEmpty(errors)) { - System.out.println("Assessment passed with no errors! \uD83D\uDE0E"); + rs.append("Assessment passed with no errors! \uD83D\uDE0E\n"); } else { - System.out.println("Assessment found the following " + StringUtils.plural(errors, "error", "errors") + ": \uD83D\uDE32"); + rs.append("Assessment found the following ").append(StringUtils.plural(errors, "error", "errors")).append(": \uD83D\uDE32\n"); for(String error : errors) { - System.out.println(" - " + error); + rs.append(" - ").append(error).append("\n"); } } @@ -101,11 +103,11 @@ public class QInstanceAssessor ///////////////////////////////////// if(CollectionUtils.nullSafeHasContents(warnings)) { - System.out.println("\nAssessment found the following " + StringUtils.plural(warnings, "warning", "warnings") + ": \uD83E\uDD28"); + rs.append("\nAssessment found the following ").append(StringUtils.plural(warnings, "warning", "warnings")).append(": \uD83E\uDD28\n"); for(String warning : warnings) { - System.out.println(" - " + warning); + rs.append(" - ").append(warning).append("\n"); } } @@ -114,13 +116,15 @@ public class QInstanceAssessor ////////////////////////////////////////// if(CollectionUtils.nullSafeHasContents(suggestions)) { - System.out.println("\nThe following " + StringUtils.plural(suggestions, "fix is", "fixes are") + " suggested: \uD83E\uDD13"); + rs.append("\nThe following ").append(StringUtils.plural(suggestions, "fix is", "fixes are")).append(" suggested: \uD83E\uDD13\n"); for(String suggestion : suggestions) { - System.out.println("\n" + suggestion + "\n"); + rs.append("\n").append(suggestion).append("\n\n"); } } + + return (rs.toString()); } 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 index 75afb935..a8774814 100644 --- 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 @@ -22,10 +22,17 @@ package com.kingsrook.qqq.backend.module.rdbms.model.metadata; +import java.sql.Connection; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.instances.assessment.QInstanceAssessor; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; import com.kingsrook.qqq.backend.module.rdbms.BaseTest; import com.kingsrook.qqq.backend.module.rdbms.TestUtils; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -36,6 +43,9 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; *******************************************************************************/ class RDBMSBackendAssessorTest extends BaseTest { + private static final QLogger LOG = QLogger.getLogger(RDBMSBackendAssessorTest.class); + + /******************************************************************************* ** @@ -46,7 +56,7 @@ class RDBMSBackendAssessorTest extends BaseTest TestUtils.primeTestDatabase("prime-test-database.sql"); QInstanceAssessor assessor = new QInstanceAssessor(QContext.getQInstance()); assessor.assess(); - assessor.printSummary(); + System.out.println(assessor.getSummary()); assertEquals(0, assessor.getErrors().size()); assertEquals(0, assessor.getWarnings().size()); assertEquals(0, assessor.getExitCode()); @@ -58,7 +68,40 @@ class RDBMSBackendAssessorTest extends BaseTest ** *******************************************************************************/ @Test - void testIssues() throws Exception + void testTableIssues() throws Exception + { + /////////////////////////////////////////////////////////////////////////////// + // start from primed database, but make a few alters to it and the meta-data // + /////////////////////////////////////////////////////////////////////////////// + TestUtils.primeTestDatabase("prime-test-database.sql"); + ConnectionManager connectionManager = new ConnectionManager(); + try(Connection connection = connectionManager.getConnection(TestUtils.defineBackend())) + { + QueryManager.executeUpdate(connection, "ALTER TABLE person ADD COLUMN suffix VARCHAR(20)"); + QueryManager.executeUpdate(connection, "ALTER TABLE person ADD UNIQUE u_name (first_name, last_name)"); + } + + QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON) + .withField(new QFieldMetaData("middleName", QFieldType.STRING)) + .withUniqueKey(new UniqueKey("firstName", "middleName", "lastName")); + + /////////////////////////// + // un-prime the database // + /////////////////////////// + QInstanceAssessor assessor = new QInstanceAssessor(QContext.getQInstance()); + assessor.assess(); + LOG.info(assessor.getSummary()); + assertNotEquals(0, assessor.getErrors().size()); + assertNotEquals(0, assessor.getExitCode()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testTotalFailure() throws Exception { /////////////////////////// // un-prime the database // @@ -66,7 +109,7 @@ class RDBMSBackendAssessorTest extends BaseTest TestUtils.primeTestDatabase("drop-test-database.sql"); QInstanceAssessor assessor = new QInstanceAssessor(QContext.getQInstance()); assessor.assess(); - assessor.printSummary(); + System.out.println(assessor.getSummary()); assertNotEquals(0, assessor.getErrors().size()); assertNotEquals(0, assessor.getExitCode()); } From cb6101d0ed5b8e62443c2210f51ac9afb794708e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 16 Jun 2025 09:43:34 -0500 Subject: [PATCH 29/35] Add action flags to insert, update, delete inputs --- .../core/actions/tables/DeleteAction.java | 1 + .../core/actions/tables/InsertAction.java | 8 +-- .../core/actions/tables/ReplaceAction.java | 3 + .../core/actions/tables/UpdateAction.java | 3 + .../core/model/actions/tables/ActionFlag.java | 35 +++++++++ .../actions/tables/delete/DeleteInput.java | 66 +++++++++++++++++ .../actions/tables/insert/InsertInput.java | 66 +++++++++++++++++ .../actions/tables/replace/ReplaceInput.java | 72 ++++++++++++++++++- .../actions/tables/update/UpdateInput.java | 66 +++++++++++++++++ 9 files changed, 313 insertions(+), 7 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/ActionFlag.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java index a5028027..17a96091 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java @@ -401,6 +401,7 @@ public class DeleteAction if(CollectionUtils.nullSafeHasContents(associatedKeys)) { DeleteInput nextLevelDeleteInput = new DeleteInput(); + nextLevelDeleteInput.setFlags(deleteInput.getFlags()); nextLevelDeleteInput.setTransaction(deleteInput.getTransaction()); nextLevelDeleteInput.setTableName(association.getAssociatedTableName()); nextLevelDeleteInput.setPrimaryKeys(associatedKeys); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java index 29266e13..e56da2b7 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java @@ -34,7 +34,6 @@ import java.util.Set; import java.util.stream.Collectors; import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction; import com.kingsrook.qqq.backend.core.actions.ActionHelper; -import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction; import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus; import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater; @@ -158,7 +157,7 @@ public class InsertAction extends AbstractQActionFunction insertedRecords, QBackendTransaction transaction) throws QException + private void manageAssociations(QTableMetaData table, List insertedRecords, InsertInput insertInput) throws QException { for(Association association : CollectionUtils.nonNullList(table.getAssociations())) { @@ -419,7 +418,8 @@ public class InsertAction extends AbstractQActionFunction r.getValue(associatedTable.getPrimaryKeyField())).collect(Collectors.toList())); @@ -617,6 +618,7 @@ public class UpdateAction LOG.debug("Updating associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", nextLevelUpdates.size())); UpdateInput nextLevelUpdateInput = new UpdateInput(); nextLevelUpdateInput.setTransaction(updateInput.getTransaction()); + nextLevelUpdateInput.setFlags(updateInput.getFlags()); nextLevelUpdateInput.setTableName(association.getAssociatedTableName()); nextLevelUpdateInput.setRecords(nextLevelUpdates); UpdateOutput nextLevelUpdateOutput = new UpdateAction().execute(nextLevelUpdateInput); @@ -627,6 +629,7 @@ public class UpdateAction LOG.debug("Inserting associatedRecords", logPair("associatedTable", associatedTable.getName()), logPair("noOfRecords", nextLevelUpdates.size())); InsertInput nextLevelInsertInput = new InsertInput(); nextLevelInsertInput.setTransaction(updateInput.getTransaction()); + nextLevelInsertInput.setFlags(updateInput.getFlags()); nextLevelInsertInput.setTableName(association.getAssociatedTableName()); nextLevelInsertInput.setRecords(nextLevelInserts); InsertOutput nextLevelInsertOutput = new InsertAction().execute(nextLevelInsertInput); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/ActionFlag.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/ActionFlag.java new file mode 100644 index 00000000..eacb5923 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/ActionFlag.java @@ -0,0 +1,35 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.actions.tables; + + +import java.io.Serializable; + + +/******************************************************************************* + ** interface to mark enums (presumably classes too, but the original intent is + ** enums) that can be added to insert/update/delete action inputs to flag behaviors + *******************************************************************************/ +public interface ActionFlag extends Serializable +{ + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java index c7a92518..9c555f53 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java @@ -24,9 +24,12 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.delete; import java.io.Serializable; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag; import com.kingsrook.qqq.backend.core.model.actions.tables.InputSource; import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; @@ -47,6 +50,8 @@ public class DeleteInput extends AbstractTableActionInput private boolean omitDmlAudit = false; private String auditContext = null; + private Set flags; + /******************************************************************************* @@ -295,4 +300,65 @@ public class DeleteInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for flags + *******************************************************************************/ + public Set getFlags() + { + return (this.flags); + } + + + + /******************************************************************************* + ** Setter for flags + *******************************************************************************/ + public void setFlags(Set flags) + { + this.flags = flags; + } + + + + /******************************************************************************* + ** Fluent setter for flags + *******************************************************************************/ + public DeleteInput withFlags(Set flags) + { + this.flags = flags; + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public DeleteInput withFlag(ActionFlag flag) + { + if(this.flags == null) + { + this.flags = new HashSet<>(); + } + this.flags.add(flag); + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public boolean hasFlag(ActionFlag flag) + { + if(this.flags == null) + { + return (false); + } + + return (this.flags.contains(flag)); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java index 1753f61f..0bf2d301 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/insert/InsertInput.java @@ -23,9 +23,12 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.insert; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag; import com.kingsrook.qqq.backend.core.model.actions.tables.InputSource; import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -48,6 +51,8 @@ public class InsertInput extends AbstractTableActionInput private boolean omitDmlAudit = false; private String auditContext = null; + private Set flags; + /******************************************************************************* @@ -316,4 +321,65 @@ public class InsertInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for flags + *******************************************************************************/ + public Set getFlags() + { + return (this.flags); + } + + + + /******************************************************************************* + ** Setter for flags + *******************************************************************************/ + public void setFlags(Set flags) + { + this.flags = flags; + } + + + + /******************************************************************************* + ** Fluent setter for flags + *******************************************************************************/ + public InsertInput withFlags(Set flags) + { + this.flags = flags; + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public InsertInput withFlag(ActionFlag flag) + { + if(this.flags == null) + { + this.flags = new HashSet<>(); + } + this.flags.add(flag); + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public boolean hasFlag(ActionFlag flag) + { + if(this.flags == null) + { + return (false); + } + + return (this.flags.contains(flag)); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java index e518dec1..542c8045 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/replace/ReplaceInput.java @@ -22,9 +22,12 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.replace; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; @@ -39,12 +42,14 @@ public class ReplaceInput extends AbstractTableActionInput private UniqueKey key; private List records; private QQueryFilter filter; - private boolean performDeletes = true; - private boolean allowNullKeyValuesToEqual = false; - private boolean setPrimaryKeyInInsertedRecords = false; + private boolean performDeletes = true; + private boolean allowNullKeyValuesToEqual = false; + private boolean setPrimaryKeyInInsertedRecords = false; private boolean omitDmlAudit = false; + private Set flags; + /******************************************************************************* @@ -303,4 +308,65 @@ public class ReplaceInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for flags + *******************************************************************************/ + public Set getFlags() + { + return (this.flags); + } + + + + /******************************************************************************* + ** Setter for flags + *******************************************************************************/ + public void setFlags(Set flags) + { + this.flags = flags; + } + + + + /******************************************************************************* + ** Fluent setter for flags + *******************************************************************************/ + public ReplaceInput withFlags(Set flags) + { + this.flags = flags; + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public ReplaceInput withFlag(ActionFlag flag) + { + if(this.flags == null) + { + this.flags = new HashSet<>(); + } + this.flags.add(flag); + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public boolean hasFlag(ActionFlag flag) + { + if(this.flags == null) + { + return (false); + } + + return (this.flags.contains(flag)); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java index 767b9ee5..d3645d37 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/update/UpdateInput.java @@ -23,9 +23,12 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.update; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag; import com.kingsrook.qqq.backend.core.model.actions.tables.InputSource; import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -56,6 +59,8 @@ public class UpdateInput extends AbstractTableActionInput private boolean omitModifyDateUpdate = false; private String auditContext = null; + private Set flags; + /******************************************************************************* @@ -385,4 +390,65 @@ public class UpdateInput extends AbstractTableActionInput return (this); } + + + /******************************************************************************* + ** Getter for flags + *******************************************************************************/ + public Set getFlags() + { + return (this.flags); + } + + + + /******************************************************************************* + ** Setter for flags + *******************************************************************************/ + public void setFlags(Set flags) + { + this.flags = flags; + } + + + + /******************************************************************************* + ** Fluent setter for flags + *******************************************************************************/ + public UpdateInput withFlags(Set flags) + { + this.flags = flags; + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public UpdateInput withFlag(ActionFlag flag) + { + if(this.flags == null) + { + this.flags = new HashSet<>(); + } + this.flags.add(flag); + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public boolean hasFlag(ActionFlag flag) + { + if(this.flags == null) + { + return (false); + } + + return (this.flags.contains(flag)); + } + } From 4fd68f9195555b9eccac3e79a677cf489bfd3e61 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 18 Jun 2025 15:37:15 -0500 Subject: [PATCH 30/35] Initial checkin --- .../actions/customizers/MultiCustomizer.java | 226 ++++++++++++++++++ .../customizers/MultiCustomizerTest.java | 141 +++++++++++ 2 files changed, 367 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizer.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizerTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizer.java new file mode 100644 index 00000000..24992700 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizer.java @@ -0,0 +1,226 @@ +/* + * 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.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties; +import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; + + +/******************************************************************************* + ** Implementation of TableCustomizerInterface that runs multiple other customizers + *******************************************************************************/ +public class MultiCustomizer implements InitializableViaCodeReference, TableCustomizerInterface +{ + private static final String KEY_CODE_REFERENCES = "codeReferences"; + + private List customizers = new ArrayList<>(); + + + /*************************************************************************** + * Factory method that builds a {@link QCodeReferenceWithProperties} that will + * allow this multi-customizer to be assigned to a table, and to track + * in that code ref's properties, the "sub" QCodeReferences to be used. + * + * Added to a table as in: + *
+    * table.withCustomizer(TableCustomizers.POST_INSERT_RECORD,
+    *    MultiCustomizer.of(QCodeReference(x), QCodeReference(y)));
+    * 
+ * + * @param codeReferences + * one or more {@link QCodeReference objects} to run when this customizer + * runs. note that they will run in the order provided in this list. + ***************************************************************************/ + public static QCodeReferenceWithProperties of(QCodeReference... codeReferences) + { + ArrayList list = new ArrayList<>(Arrays.stream(codeReferences).toList()); + return (new QCodeReferenceWithProperties(MultiCustomizer.class, MapBuilder.of(KEY_CODE_REFERENCES, list))); + } + + + /*************************************************************************** + * Add an additional table customizer code reference to an existing + * codeReference, e.g., constructed by the `of` factory method. + * + * @see #of(QCodeReference...) + ***************************************************************************/ + public static void addTableCustomizer(QCodeReferenceWithProperties existingMultiCustomizerCodeReference, QCodeReference codeReference) + { + ArrayList list = (ArrayList) existingMultiCustomizerCodeReference.getProperties().computeIfAbsent(KEY_CODE_REFERENCES, key -> new ArrayList<>()); + list.add(codeReference); + } + + + + /*************************************************************************** + * When this class is instantiated by the QCodeLoader, initialize the + * sub-customizer objects. + ***************************************************************************/ + @Override + public void initialize(QCodeReference codeReference) + { + if(codeReference instanceof QCodeReferenceWithProperties codeReferenceWithProperties) + { + Serializable codeReferencesPropertyValue = codeReferenceWithProperties.getProperties().get(KEY_CODE_REFERENCES); + if(codeReferencesPropertyValue instanceof List list) + { + for(Object o : list) + { + if(o instanceof QCodeReference reference) + { + TableCustomizerInterface customizer = QCodeLoader.getAdHoc(TableCustomizerInterface.class, reference); + customizers.add(customizer); + } + } + } + else + { + LOG.warn("Property KEY_CODE_REFERENCES [" + KEY_CODE_REFERENCES + "] must be a List."); + } + } + + if(customizers.isEmpty()) + { + LOG.info("No TableCustomizers were specified for MultiCustomizer."); + } + } + + + + /*************************************************************************** + * run postQuery method over all sub-customizers + ***************************************************************************/ + @Override + public List postQuery(QueryOrGetInputInterface queryInput, List records) throws QException + { + for(TableCustomizerInterface customizer : customizers) + { + records = customizer.postQuery(queryInput, records); + } + return records; + } + + + + /*************************************************************************** + * run preInsert method over all sub-customizers + ***************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + for(TableCustomizerInterface customizer : customizers) + { + records = customizer.preInsert(insertInput, records, isPreview); + } + return records; + } + + + + /*************************************************************************** + * run postInsert method over all sub-customizers + ***************************************************************************/ + @Override + public List postInsert(InsertInput insertInput, List records) throws QException + { + for(TableCustomizerInterface customizer : customizers) + { + records = customizer.postInsert(insertInput, records); + } + return records; + } + + + + /*************************************************************************** + * run preUpdate method over all sub-customizers + ***************************************************************************/ + @Override + public List preUpdate(UpdateInput updateInput, List records, boolean isPreview, Optional> oldRecordList) throws QException + { + for(TableCustomizerInterface customizer : customizers) + { + records = customizer.preUpdate(updateInput, records, isPreview, oldRecordList); + } + return records; + } + + + + /*************************************************************************** + * run postUpdate method over all sub-customizers + ***************************************************************************/ + @Override + public List postUpdate(UpdateInput updateInput, List records, Optional> oldRecordList) throws QException + { + for(TableCustomizerInterface customizer : customizers) + { + records = customizer.postUpdate(updateInput, records, oldRecordList); + } + return records; + } + + + + /*************************************************************************** + * run preDelete method over all sub-customizers + ***************************************************************************/ + @Override + public List preDelete(DeleteInput deleteInput, List records, boolean isPreview) throws QException + { + for(TableCustomizerInterface customizer : customizers) + { + records = customizer.preDelete(deleteInput, records, isPreview); + } + return records; + } + + + + /*************************************************************************** + * run postDelete method over all sub-customizers + ***************************************************************************/ + @Override + public List postDelete(DeleteInput deleteInput, List records) throws QException + { + for(TableCustomizerInterface customizer : customizers) + { + records = customizer.postDelete(deleteInput, records); + } + return records; + } + +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizerTest.java new file mode 100644 index 00000000..64508617 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/customizers/MultiCustomizerTest.java @@ -0,0 +1,141 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.actions.customizers; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Unit test for MultiCustomizer + *******************************************************************************/ +class MultiCustomizerTest extends BaseTest +{ + private static List events = new ArrayList<>(); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + @AfterEach + void beforeAndAfterEach() + { + events.clear(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + QInstance qInstance = TestUtils.defineInstance(); + qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withCustomizer(TableCustomizers.PRE_INSERT_RECORD, MultiCustomizer.of( + new QCodeReference(CustomizerA.class), + new QCodeReference(CustomizerB.class) + )); + reInitInstanceInContext(qInstance); + + new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecord(new QRecord())); + assertThat(events).hasSize(2) + .contains("CustomizerA.preInsert") + .contains("CustomizerB.preInsert"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testAddingMore() throws QException + { + QInstance qInstance = TestUtils.defineInstance(); + + QCodeReferenceWithProperties multiCustomizer = MultiCustomizer.of(new QCodeReference(CustomizerA.class)); + MultiCustomizer.addTableCustomizer(multiCustomizer, new QCodeReference(CustomizerB.class)); + + qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).withCustomizer(TableCustomizers.PRE_INSERT_RECORD, multiCustomizer); + reInitInstanceInContext(qInstance); + + new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecord(new QRecord())); + assertThat(events).hasSize(2) + .contains("CustomizerA.preInsert") + .contains("CustomizerB.preInsert"); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public static class CustomizerA implements TableCustomizerInterface + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + events.add("CustomizerA.preInsert"); + return (records); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public static class CustomizerB implements TableCustomizerInterface + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List preInsert(InsertInput insertInput, List records, boolean isPreview) throws QException + { + events.add("CustomizerB.preInsert"); + return (records); + } + } + +} \ No newline at end of file From 5327424cecd4924044d9b3a94236ccbfd5153e6c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 18 Jun 2025 15:37:41 -0500 Subject: [PATCH 31/35] Add QException to some methods in here. --- .../actions/values/BasicCustomPossibleValueProvider.java | 6 +++--- .../core/actions/values/QCustomPossibleValueProvider.java | 2 +- .../tables/TablesCustomPossibleValueProviderTest.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/BasicCustomPossibleValueProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/BasicCustomPossibleValueProvider.java index da1421bc..fe6faa12 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/BasicCustomPossibleValueProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/BasicCustomPossibleValueProvider.java @@ -47,12 +47,12 @@ public abstract class BasicCustomPossibleValueProvider getAllSourceObjects(); + protected abstract List getAllSourceObjects() throws QException; @@ -60,7 +60,7 @@ public abstract class BasicCustomPossibleValueProvider getPossibleValue(Serializable idValue) + public QPossibleValue getPossibleValue(Serializable idValue) throws QException { S sourceObject = getSourceObject(idValue); if(sourceObject == null) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java index d32e4e79..b27097a3 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QCustomPossibleValueProvider.java @@ -45,7 +45,7 @@ public interface QCustomPossibleValueProvider /******************************************************************************* ** *******************************************************************************/ - QPossibleValue getPossibleValue(Serializable idValue); + QPossibleValue getPossibleValue(Serializable idValue) throws QException; /******************************************************************************* ** diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java index 4bdbbc06..5bf5217b 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/tables/TablesCustomPossibleValueProviderTest.java @@ -80,7 +80,7 @@ class TablesCustomPossibleValueProviderTest extends BaseTest ** *******************************************************************************/ @Test - void testGetPossibleValue() + void testGetPossibleValue() throws QException { TablesCustomPossibleValueProvider provider = new TablesCustomPossibleValueProvider(); From 9b2c28143100c39d78b4c079eb032a6b1fd9f28d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 18 Jun 2025 15:38:49 -0500 Subject: [PATCH 32/35] Change the QQQTable PVS to be custom type, with permissions applied to the list of tables you see. --- .../QQQTableCustomPossibleValueProvider.java | 111 +++++++++++ .../tables/QQQTablesMetaDataProvider.java | 10 +- ...QTableCustomPossibleValueProviderTest.java | 172 ++++++++++++++++++ 3 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java new file mode 100644 index 00000000..af4b3791 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java @@ -0,0 +1,111 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.tables; + + +import java.io.Serializable; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.values.BasicCustomPossibleValueProvider; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** possible-value source provider for the `QQQ Table` PVS - a list of all tables + ** in an application/qInstance (that you have permission to see) + *******************************************************************************/ +public class QQQTableCustomPossibleValueProvider extends BasicCustomPossibleValueProvider +{ + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + protected QPossibleValue makePossibleValue(QRecord sourceObject) + { + return (new QPossibleValue<>(sourceObject.getValueInteger("id"), sourceObject.getValueString("label"))); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + protected QRecord getSourceObject(Serializable id) throws QException + { + QRecord qqqTableRecord = GetAction.execute(QQQTable.TABLE_NAME, id); + if(qqqTableRecord == null) + { + return (null); + } + + QTableMetaData table = QContext.getQInstance().getTable(qqqTableRecord.getValueString("name")); + return isTableAllowed(table) ? qqqTableRecord : null; + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + protected List getAllSourceObjects() throws QException + { + return (QueryAction.execute(QQQTable.TABLE_NAME, null)); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private boolean isTableAllowed(QTableMetaData table) + { + if(table == null) + { + return (false); + } + + if(table.getIsHidden()) + { + return (false); + } + + PermissionCheckResult permissionCheckResult = PermissionsHelper.getPermissionCheckResult(new QueryInput(table.getName()), table); + if(!PermissionCheckResult.ALLOW.equals(permissionCheckResult)) + { + return (false); + } + + return (true); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTablesMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTablesMetaDataProvider.java index e880a230..91712ab9 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTablesMetaDataProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTablesMetaDataProvider.java @@ -27,6 +27,9 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PVSValueFormatAndFields; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; @@ -125,10 +128,11 @@ public class QQQTablesMetaDataProvider public QPossibleValueSource defineQQQTablePossibleValueSource() { return (new QPossibleValueSource() - .withType(QPossibleValueSourceType.TABLE) .withName(QQQTable.TABLE_NAME) - .withTableName(QQQTable.TABLE_NAME)) - .withOrderByField("label"); + .withType(QPossibleValueSourceType.CUSTOM) + .withIdType(QFieldType.INTEGER) + .withCustomCodeReference(new QCodeReference(QQQTableCustomPossibleValueProvider.class)) + .withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY)); } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java new file mode 100644 index 00000000..a573b2d6 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java @@ -0,0 +1,172 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.tables; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesCustomPossibleValueProvider; +import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for QQQTableCustomPossibleValueProvider + *******************************************************************************/ +class QQQTableCustomPossibleValueProviderTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws QException + { + QInstance qInstance = TestUtils.defineInstance(); + + qInstance.addTable(new QTableMetaData() + .withName("hidden") + .withIsHidden(true) + .withBackendName(TestUtils.MEMORY_BACKEND_NAME) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER))); + + qInstance.addTable(new QTableMetaData() + .withName("restricted") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)) + .withBackendName(TestUtils.MEMORY_BACKEND_NAME) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER))); + + new QQQTablesMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, TestUtils.MEMORY_BACKEND_NAME, null); + + QContext.init(qInstance, newSession()); + + for(String tableName : qInstance.getTables().keySet()) + { + QQQTableTableManager.getQQQTableId(qInstance, tableName); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testGetPossibleValue() throws QException + { + Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_PERSON); + + QQQTableCustomPossibleValueProvider provider = new QQQTableCustomPossibleValueProvider(); + + QPossibleValue possibleValue = provider.getPossibleValue(personTableId); + assertEquals(personTableId, possibleValue.getId()); + assertEquals("Person", possibleValue.getLabel()); + + assertNull(provider.getPossibleValue(-1)); + + Integer hiddenTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "hidden"); + assertNull(provider.getPossibleValue(hiddenTableId)); + + Integer restrictedTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "restricted"); + assertNull(provider.getPossibleValue(restrictedTableId)); + + QContext.getQSession().withPermission("restricted.hasAccess"); + assertNotNull(provider.getPossibleValue(restrictedTableId)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSearchPossibleValue() throws QException + { + TablesCustomPossibleValueProvider provider = new TablesCustomPossibleValueProvider(); + + List> list = provider.search(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME)); + assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); + assertThat(list).noneMatch(p -> p.getId().equals("no-such-table")); + assertThat(list).noneMatch(p -> p.getId().equals("hidden")); + assertThat(list).noneMatch(p -> p.getId().equals("restricted")); + assertNull(provider.getPossibleValue("restricted")); + + list = provider.search(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withIdList(List.of(TestUtils.TABLE_NAME_PERSON, TestUtils.TABLE_NAME_SHAPE, "hidden"))); + assertEquals(2, list.size()); + assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); + assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_SHAPE)); + assertThat(list).noneMatch(p -> p.getId().equals("hidden")); + + list = provider.search(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withLabelList(List.of("Person", "Shape", "Restricted"))); + assertEquals(2, list.size()); + assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); + assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_SHAPE)); + assertThat(list).noneMatch(p -> p.getId().equals("restricted")); + + list = provider.search(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withSearchTerm("restricted")); + assertEquals(0, list.size()); + + ///////////////////////////////////////// + // add permission for restricted table // + ///////////////////////////////////////// + QContext.getQSession().withPermission("restricted.hasAccess"); + list = provider.search(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withSearchTerm("restricted")); + assertEquals(1, list.size()); + + list = provider.search(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withLabelList(List.of("Person", "Shape", "Restricted"))); + assertEquals(3, list.size()); + assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); + assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_SHAPE)); + assertThat(list).anyMatch(p -> p.getId().equals("restricted")); + + } + +} \ No newline at end of file From 5c02c1fd2e4644fde8b5caee7a67c08332184b1e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 18 Jun 2025 15:50:14 -0500 Subject: [PATCH 33/35] Add action flags to allow sync-scheduled job processes to be omitted. --- ...stractRecordSyncToScheduledJobProcess.java | 24 ++++-- ...BaseSyncToScheduledJobTableCustomizer.java | 78 +++++++++++++++---- 2 files changed, 82 insertions(+), 20 deletions(-) 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 index 65ed317c..90df487b 100644 --- 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 @@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; @@ -52,10 +53,10 @@ 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. - ** + * Base class to manage creating scheduled jobs based on records in another table + * + * Expected to be used via BaseSyncToScheduledJobTableCustomizer - see its javadoc. + * @see BaseSyncToScheduledJobTableCustomizer *******************************************************************************/ public abstract class AbstractRecordSyncToScheduledJobProcess extends AbstractTableSyncTransformStep implements MetaDataProducerInterface { @@ -65,6 +66,20 @@ public abstract class AbstractRecordSyncToScheduledJobProcess extends AbstractTa + /*************************************************************************** + * action flags that can be put in an insert/update/delete input to control + * behavior of this process. + ***************************************************************************/ + public enum ActionFlags implements ActionFlag + { + /*************************************************************************** + * tell this process not to run upon such an action taken on the source table. + ***************************************************************************/ + DO_NOT_SYNC + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -89,7 +104,6 @@ public abstract class AbstractRecordSyncToScheduledJobProcess extends AbstractTa - /******************************************************************************* ** *******************************************************************************/ 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 index aebd82c1..ce36a096 100644 --- 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 @@ -40,9 +40,11 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; @@ -84,16 +86,44 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt /*************************************************************************** - ** + * Create a {@link QCodeReferenceWithProperties} that can be used to add this + * class to a table. + * + * If this is the only customizer for the post insert/update/delete events + * on your table, you can instead call setTableCustomizers. But if you want, + * for example, a sync-scheduled-job (what this customizer does) plus some other + * customizers, then you can call this method to get a code reference that you + * can add, for example, to {@link com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer} + * + * @param tableMetaData the table that the customizer will be used on. + * @param syncProcess instance of the subclass of AbstractRecordSyncToScheduledJobProcess + * that should run in the table's post insert/update/delete + * events. + * @see #setTableCustomizers(QTableMetaData, AbstractRecordSyncToScheduledJobProcess) ***************************************************************************/ - public static void setTableCustomizers(QTableMetaData tableMetaData, AbstractRecordSyncToScheduledJobProcess syncProcess) + public static QCodeReferenceWithProperties makeCodeReference(QTableMetaData tableMetaData, AbstractRecordSyncToScheduledJobProcess syncProcess) { - QCodeReference codeReference = new QCodeReferenceWithProperties(BaseSyncToScheduledJobTableCustomizer.class, Map.of( + return new QCodeReferenceWithProperties(BaseSyncToScheduledJobTableCustomizer.class, Map.of( KEY_TABLE_NAME, tableMetaData.getName(), KEY_SYNC_PROCESS_NAME, syncProcess.getClass().getSimpleName(), KEY_SCHEDULED_JOB_FOREIGN_KEY_TYPE, syncProcess.getScheduledJobForeignKeyType() )); + } + + + /*************************************************************************** + * Add post insert/update/delete customizers to a table, that will run a + * sync-scheduled-job process. + * + * @param tableMetaData the table that the customizer will be used on. + * @param syncProcess instance of the subclass of AbstractRecordSyncToScheduledJobProcess + * that should run in the table's post insert/update/delete + * events. + ***************************************************************************/ + public static void setTableCustomizers(QTableMetaData tableMetaData, AbstractRecordSyncToScheduledJobProcess syncProcess) + { + QCodeReference codeReference = makeCodeReference(tableMetaData, syncProcess); tableMetaData.withCustomizer(TableCustomizers.POST_INSERT_RECORD, codeReference); tableMetaData.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, codeReference); tableMetaData.withCustomizer(TableCustomizers.POST_DELETE_RECORD, codeReference); @@ -138,6 +168,16 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt @Override public List postInsertOrUpdate(AbstractActionInput input, List records, Optional> oldRecordList) throws QException { + if(input instanceof UpdateInput updateInput && updateInput.hasFlag(AbstractRecordSyncToScheduledJobProcess.ActionFlags.DO_NOT_SYNC)) + { + return records; + } + + if(input instanceof InsertInput insertInput && insertInput.hasFlag(AbstractRecordSyncToScheduledJobProcess.ActionFlags.DO_NOT_SYNC)) + { + return records; + } + runSyncProcessForRecordList(records, syncProcessName); return records; } @@ -157,7 +197,17 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt /*************************************************************************** - ** + * Run the named process over a set of records (e.g., that were inserted or + * updated). + * + * This method is normally called from within this class, in postInsertOrUpdate. + * + * Note that if the {@link ScheduledJob} table isn't defined in the QInstance, + * that the process will not be called. + * + * @param records list of records to use as source records in the table-sync + * to the scheduledJob table. + * @param processName name of the sync-process to run. ***************************************************************************/ public void runSyncProcessForRecordList(List records, String processName) { @@ -199,7 +249,15 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt /*************************************************************************** - ** + * Delete scheduled job records for source-table records that have been deleted. + * + * This method is normally called from within this class, in postDelete. + * + * Note that if the {@link ScheduledJob} table isn't defined in the QInstance, + * that the process will not be called. + * + * @param records list of records to use as foreign-key sources to identify + * scheduledJob records to delete ***************************************************************************/ public void deleteScheduledJobsForRecordList(List records) { @@ -296,15 +354,6 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt } - /******************************************************************************* - ** 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 @@ -335,5 +384,4 @@ public class BaseSyncToScheduledJobTableCustomizer implements TableCustomizerInt return (this); } - } From 96217c839dc7e6ae2ac157d0f1ad134157d4331e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 18 Jun 2025 16:37:27 -0500 Subject: [PATCH 34/35] Fixed test (was a copy-paste job, hadn't been finished) and fixed to filter tables in the query method --- .../QQQTableCustomPossibleValueProvider.java | 14 ++++- ...QTableCustomPossibleValueProviderTest.java | 56 ++++++++++--------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java index af4b3791..b6ac044b 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProvider.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.tables; import java.io.Serializable; +import java.util.ArrayList; import java.util.List; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; @@ -79,7 +80,18 @@ public class QQQTableCustomPossibleValueProvider extends BasicCustomPossibleValu @Override protected List getAllSourceObjects() throws QException { - return (QueryAction.execute(QQQTable.TABLE_NAME, null)); + List records = QueryAction.execute(QQQTable.TABLE_NAME, null); + ArrayList rs = new ArrayList<>(); + for(QRecord record : records) + { + QTableMetaData table = QContext.getQInstance().getTable(record.getValueString("name")); + if(isTableAllowed(table)) + { + rs.add(record); + } + } + + return rs; } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java index a573b2d6..6412df35 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/tables/QQQTableCustomPossibleValueProviderTest.java @@ -34,8 +34,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesCustomPossibleValueProvider; -import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; import com.kingsrook.qqq.backend.core.utils.TestUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -91,9 +89,8 @@ class QQQTableCustomPossibleValueProviderTest extends BaseTest @Test void testGetPossibleValue() throws QException { - Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_PERSON); - - QQQTableCustomPossibleValueProvider provider = new QQQTableCustomPossibleValueProvider(); + Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_PERSON); + QQQTableCustomPossibleValueProvider provider = new QQQTableCustomPossibleValueProvider(); QPossibleValue possibleValue = provider.getPossibleValue(personTableId); assertEquals(personTableId, possibleValue.getId()); @@ -119,34 +116,39 @@ class QQQTableCustomPossibleValueProviderTest extends BaseTest @Test void testSearchPossibleValue() throws QException { - TablesCustomPossibleValueProvider provider = new TablesCustomPossibleValueProvider(); + Integer personTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_PERSON); + Integer shapeTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), TestUtils.TABLE_NAME_SHAPE); + Integer hiddenTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "hidden"); + Integer restrictedTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), "restricted"); - List> list = provider.search(new SearchPossibleValueSourceInput() - .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME)); - assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); - assertThat(list).noneMatch(p -> p.getId().equals("no-such-table")); - assertThat(list).noneMatch(p -> p.getId().equals("hidden")); - assertThat(list).noneMatch(p -> p.getId().equals("restricted")); + QQQTableCustomPossibleValueProvider provider = new QQQTableCustomPossibleValueProvider(); + + List> list = provider.search(new SearchPossibleValueSourceInput() + .withPossibleValueSourceName(QQQTable.TABLE_NAME)); + assertThat(list).anyMatch(p -> p.getId().equals(personTableId)); + assertThat(list).noneMatch(p -> p.getId().equals(-1)); + assertThat(list).noneMatch(p -> p.getId().equals(hiddenTableId)); + assertThat(list).noneMatch(p -> p.getId().equals(restrictedTableId)); assertNull(provider.getPossibleValue("restricted")); list = provider.search(new SearchPossibleValueSourceInput() - .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) - .withIdList(List.of(TestUtils.TABLE_NAME_PERSON, TestUtils.TABLE_NAME_SHAPE, "hidden"))); + .withPossibleValueSourceName(QQQTable.TABLE_NAME) + .withIdList(List.of(personTableId, shapeTableId, hiddenTableId))); assertEquals(2, list.size()); - assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); - assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_SHAPE)); - assertThat(list).noneMatch(p -> p.getId().equals("hidden")); + assertThat(list).anyMatch(p -> p.getId().equals(personTableId)); + assertThat(list).anyMatch(p -> p.getId().equals(shapeTableId)); + assertThat(list).noneMatch(p -> p.getId().equals(hiddenTableId)); list = provider.search(new SearchPossibleValueSourceInput() - .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withPossibleValueSourceName(QQQTable.TABLE_NAME) .withLabelList(List.of("Person", "Shape", "Restricted"))); assertEquals(2, list.size()); - assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); - assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_SHAPE)); - assertThat(list).noneMatch(p -> p.getId().equals("restricted")); + assertThat(list).anyMatch(p -> p.getId().equals(personTableId)); + assertThat(list).anyMatch(p -> p.getId().equals(shapeTableId)); + assertThat(list).noneMatch(p -> p.getId().equals(restrictedTableId)); list = provider.search(new SearchPossibleValueSourceInput() - .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withPossibleValueSourceName(QQQTable.TABLE_NAME) .withSearchTerm("restricted")); assertEquals(0, list.size()); @@ -155,17 +157,17 @@ class QQQTableCustomPossibleValueProviderTest extends BaseTest ///////////////////////////////////////// QContext.getQSession().withPermission("restricted.hasAccess"); list = provider.search(new SearchPossibleValueSourceInput() - .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withPossibleValueSourceName(QQQTable.TABLE_NAME) .withSearchTerm("restricted")); assertEquals(1, list.size()); list = provider.search(new SearchPossibleValueSourceInput() - .withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME) + .withPossibleValueSourceName(QQQTable.TABLE_NAME) .withLabelList(List.of("Person", "Shape", "Restricted"))); assertEquals(3, list.size()); - assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_PERSON)); - assertThat(list).anyMatch(p -> p.getId().equals(TestUtils.TABLE_NAME_SHAPE)); - assertThat(list).anyMatch(p -> p.getId().equals("restricted")); + assertThat(list).anyMatch(p -> p.getId().equals(personTableId)); + assertThat(list).anyMatch(p -> p.getId().equals(shapeTableId)); + assertThat(list).anyMatch(p -> p.getId().equals(restrictedTableId)); } From d7867b8d22996d7fe0b49b6fdfeaeb25970c55aa Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 19 Jun 2025 14:49:07 -0500 Subject: [PATCH 35/35] replace all relative program paths (e.g., cp) with absolute ones (e.g., /bin/cp), in constants (e.g., CP); --- .../com/kingsrook/qqq/devtools/CreateNewQBit.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 index 9d5a7194..e37e02df 100644 --- 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 @@ -21,7 +21,10 @@ public class CreateNewQBit private static ExecutorService executorService = null; - private static String SED = "/opt/homebrew/bin/gsed"; + private static String SED = "/opt/homebrew/bin/gsed"; // needs to be a version that supports -i (in-place edit) + private static String GIT = "/usr/bin/git"; + private static String CP = "/bin/cp"; + private static String MV = "/bin/mv"; @@ -87,7 +90,7 @@ public class CreateNewQBit System.out.println(); System.out.println("Copying template..."); - ProcessResult cpResult = run(new ProcessBuilder("cp", "-rv", template.getAbsolutePath(), dir.getAbsolutePath())); + ProcessResult cpResult = run(new ProcessBuilder(CP, "-rv", template.getAbsolutePath(), dir.getAbsolutePath())); System.out.print(cpResult.stdout()); System.out.println(); @@ -100,7 +103,7 @@ public class CreateNewQBit System.out.println(); System.out.println("Init'ing git repo..."); - run(new ProcessBuilder("git", "init").directory(dir)); + run(new ProcessBuilder(GIT, "init").directory(dir)); System.out.println(); // git remote add origin https://github.com/Kingsrook/${name}.git ? @@ -123,9 +126,9 @@ public class CreateNewQBit { 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()); + 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()); }