diff --git a/.circleci/config.yml b/.circleci/config.yml index 1045e671..1d96c5a0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -98,6 +98,31 @@ commands: - ~/.m2 key: v1-dependencies-{{ checksum "pom.xml" }} + install_asciidoctor: + steps: + - checkout + - run: + name: Install asciidoctor + command: | + sudo apt-get update + sudo apt install -y asciidoctor + + run_asciidoctor: + steps: + - run: + name: Run asciidoctor + command: | + cd docs + asciidoctor -a docinfo=shared index.adoc + + upload_docs_site: + steps: + - run: + name: scp html to justinsgotskinnylegs.com + command: | + cd docs + scp index.html dkelkhoff@45.79.44.221:/mnt/first-volume/dkelkhoff/nginx/html/justinsgotskinnylegs.com/qqq-docs.html + jobs: mvn_test: executor: localstack/default @@ -114,6 +139,13 @@ jobs: - mvn_verify - mvn_jar_deploy + publish_asciidoc: + executor: localstack/default + steps: + - install_asciidoctor + - run_asciidoctor + - upload_docs_site + workflows: test_only: jobs: @@ -134,4 +166,7 @@ workflows: only: /dev/ tags: only: /(version|snapshot)-.*/ - + - publish_asciidoc: + filters: + branches: + only: /dev/ diff --git a/docs/metaData/Fields.adoc b/docs/metaData/Fields.adoc index 93d11dcd..eb20fa84 100644 --- a/docs/metaData/Fields.adoc +++ b/docs/metaData/Fields.adoc @@ -21,5 +21,99 @@ Used to set values in the `displayValues` map within a `QRecord`. * `possibleValueSourceName` - *String* - Reference to a {link-pvs} to be used for this field. Values in this field should correspond to ids from the referenced Possible Value Source. * `maxLength` - *Integer* - Maximum length (number of characters) allowed for values in this field. -Only applicable for fields with `type=STRING`. -* ` \ No newline at end of file +Only applicable for fields with `type=STRING`. Needs to be used with a `FieldBehavior` of type `ValueTooLongBehavior`. + +==== Field Behaviors +Additional behaviors can be attached to fields through the use of the `behaviors` attribute, +which is a `Set` of 0 or more instances of implementations of the `FieldBehavior` interface. +Note that in some cases, these instances may be `enum` constants, +but other times may be regular Objects. + +QQQ provides a set of common field behaviors. +Applications can also define their own field behaviors by implementing the `FieldBehavior` interface, +and attaching instances of their custom behavior classes to fields. + +===== ValueTooLongBehavior +Used on String fields. Requires the field to have a `maxLength` set. +Depending on the chosen instance of this enum, before a record is Inserted or Updated, +if the value in the field is longer than the `maxLength`, then one of the following actions can occur: + +* `TRUNCATE` - The value will be simply truncated to the `maxLength`. +* `TRUNCATE_ELLIPSIS` - The value will be truncated to 3 characters less than the `maxLength`, and three periods (an ellipsis) will be placed at the end. +* `ERROR` - An error will be reported, and the record will not be inserted or updated. +* `PASS_THROUGH` - Nothing will happen. This is the same as not having a `ValueTooLongBehavior` on the field. + +[source,java] +.Examples of using ValueTooLongBehavior +---- + new QFieldMetaData("sku", QFieldType.STRING) + .withMaxLength(40), + .withBehavior(ValueTooLongBehavior.ERROR), + + new QFieldMetaData("reason", QFieldType.STRING) + .withMaxLength(250), + .withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS), + +---- + +===== DynamicDefaultValueBehavior +Used to set a dynamic default value to a field when it is being inserted or updated. +For example, instead of having a hard-coded `defaultValue` specified in the field meta-data, +and instead of having to add, for example, a pre-insert custom action. + +* `CREATE_DATE` - On inserts, sets the field's value to the current time. +* `MODIFY_DATE` - On inserts and updates, sets the field's value to the current time. +* `USER_ID` - On inserts and updates, sets the field's value to the current user's id (but only if the value is currently null). + +_Note that the `QInstanceEnricher` will, by default, add the `CREATE_DATE` and `MODIFY_DATE` `DynamicDefaultValueBehavior` +options to any fields named `"createDate"` or `"modifyDate"`. +This behavior can be disabled by setting the `configAddDynamicDefaultValuesToFieldsNamedCreateDateAndModifyDate` property +on the `QInstanceEnricher` instance used by the application to `false`._ + +[source,java] +.Examples of using DynamicDefaultValueBehavior +---- + new QFieldMetaData("createDate", QFieldType.DATE_TIME) + .withBehavior(DynamicDefaultValueBehavior.CREATE_DATE), + + new QFieldMetaData("modifyDate", QFieldType.DATE_TIME) + .withBehavior(DynamicDefaultValueBehavior.MODIFY_DATE), + + new QFieldMetaData("createdByUserId", QFieldType.STRING) + .withBehavior(DynamicDefaultValueBehavior.USER_ID), +---- + +===== DateTimeDisplayValueBehavior +By default, in QQQ, fields of type `DATE_TIME` are stored in UTC, +and their values in a QRecord is a java `Instant` instance, which is always UTC. +However, frontends will prefer to display date-time values in the user's local Time Zone whenever possible. + +Using `DateTimeDisplayValueBehavior` allows a `DATE_TIME` field to be displayed in a different Time Zone. +An example use-case for this would be displaying airplane flight times, +where you would want a flight from California to New York to display Pacific Time for its departure time, +and Eastern Time for its arrival. + +An instance of `DateTimeDisplayValueBehavior` can be configured to either use a hard-coded time `ZoneId` +(for example, to always show users UTC, or a business's home-office time zone). +Or, it can be set up to get the time zone to use from another field in the table. + +[source,java] +.Examples of using DateTimeDisplayValueBehavior +---- +new QTableMetaData().withName("flights").withFields(List.of( + ... + new QFieldMetaData("departureTimeZoneId", QFieldType.STRING), + new QFieldMetaData("arrivaTimeZoneId", QFieldType.STRING), + + new QFieldMetaData("departureTime", QFieldType.DATE_TIME) + .withBehavior(new DateTimeDisplayValueBehavior() + .withZoneIdFromFieldName("departureTimeZoneId")), + + new QFieldMetaData("arrivalTime", QFieldType.DATE_TIME) + .withBehavior(new DateTimeDisplayValueBehavior() + .withZoneIdFromFieldName("arrivalTimeZoneId")) + + new QFieldMetaData("ticketSaleStartDateTime", QFieldType.DATE_TIME) + .withBehavior(new DateTimeDisplayValueBehavior() + .withDefaultZoneId("UTC")) +---- diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java index 9b013a85..ba1118a4 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/automation/polling/PollingAutomationPerTableRunner.java @@ -526,7 +526,7 @@ public class PollingAutomationPerTableRunner implements Runnable // note - this method - will re-query the objects, so we should have confidence that their data is fresh... // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// List matchingQRecords = getRecordsMatchingActionFilter(table, records, action); - LOG.debug("Of the {} records that were pending automations, {} of them match the filter on the action {}", records.size(), matchingQRecords.size(), action); + LOG.debug("Of the [" + records.size() + "] records that were pending automations, [" + matchingQRecords.size() + "] of them match the filter on the action:" + action); if(CollectionUtils.nullSafeHasContents(matchingQRecords)) { LOG.debug(" Processing " + matchingQRecords.size() + " records in " + table + " for action " + action); @@ -601,7 +601,7 @@ public class PollingAutomationPerTableRunner implements Runnable /******************************************************************************* ** Finally, actually run action code against a list of known matching records. - ** todo not commit - move to somewhere genericer + ** *******************************************************************************/ public static void applyActionToMatchingRecords(QTableMetaData table, List records, TableAutomationAction action) throws Exception { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/QCodeLoader.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/QCodeLoader.java index 14773be9..b4d46ca7 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/QCodeLoader.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/QCodeLoader.java @@ -96,7 +96,7 @@ public class QCodeLoader } catch(Exception e) { - LOG.error("Error initializing customizer", logPair("codeReference", codeReference), e); + LOG.error("Error initializing customizer", e, logPair("codeReference", codeReference)); ////////////////////////////////////////////////////////////////////////////////////////////////////////// // return null here - under the assumption that during normal run-time operations, we'll never hit here // @@ -135,7 +135,7 @@ public class QCodeLoader } catch(Exception e) { - LOG.error("Error initializing customizer", logPair("codeReference", codeReference), e); + LOG.error("Error initializing customizer", e, logPair("codeReference", codeReference)); ////////////////////////////////////////////////////////////////////////////////////////////////////////// // return null here - under the assumption that during normal run-time operations, we'll never hit here // @@ -187,7 +187,7 @@ public class QCodeLoader } catch(Exception e) { - LOG.error("Error initializing customizer", logPair("codeReference", codeReference), e); + LOG.error("Error initializing customizer", e, logPair("codeReference", codeReference)); ////////////////////////////////////////////////////////////////////////////////////////////////////////// // return null here - under the assumption that during normal run-time operations, we'll never hit here // diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java index 2583855f..b3cb59a1 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java @@ -138,7 +138,7 @@ public class RecordPipe { if(now - sleepLoopStartTime > MAX_SLEEP_LOOP_MILLIS) { - LOG.warn("Giving up adding record to pipe, due to pipe being full for more than {} millis", MAX_SLEEP_LOOP_MILLIS); + LOG.warn("Giving up adding record to pipe, due to pipe being full for more than " + MAX_SLEEP_LOOP_MILLIS + " millis"); throw (new IllegalStateException("Giving up adding record to pipe, due to pipe staying full too long.")); } LOG.trace("Record pipe.add failed (due to full pipe). Blocking."); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java index 726ceea4..fab61595 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java @@ -28,7 +28,6 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -364,7 +363,9 @@ public class QValueFormatter } } - setDisplayValuesInRecord(fieldMap, record); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, records, null); + + setDisplayValuesInRecord(table, fieldMap, record, true); record.setRecordLabel(formatRecordLabel(table, record)); } } @@ -374,61 +375,49 @@ public class QValueFormatter /******************************************************************************* ** For a list of records, set their recordLabels and display values *******************************************************************************/ - public static void setDisplayValuesInRecords(Collection fields, List records) + public static void setDisplayValuesInRecords(QTableMetaData table, Map fields, List records) { if(records == null) { return; } + if(table != null) + { + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, records, null); + } + for(QRecord record : records) { - setDisplayValuesInRecord(fields, record); + setDisplayValuesInRecord(table, fields, record, true); } } /******************************************************************************* - ** For a list of records, set their recordLabels and display values + ** For a single record, set its display values - public version of this. *******************************************************************************/ - public static void setDisplayValuesInRecords(Map fields, List records) + public static void setDisplayValuesInRecord(QTableMetaData table, Map fields, QRecord record) { - if(records == null) - { - return; - } - - for(QRecord record : records) - { - setDisplayValuesInRecord(fields, record); - } + setDisplayValuesInRecord(table, fields, record, false); } - /******************************************************************************* - ** For a list of records, set their display values + ** For a single record, set its display values - where caller (meant to stay private) + ** can specify if they've already done fieldBehaviors (to avoid re-doing). *******************************************************************************/ - public static void setDisplayValuesInRecord(Collection fields, QRecord record) + private static void setDisplayValuesInRecord(QTableMetaData table, Map fields, QRecord record, boolean alreadyAppliedFieldDisplayBehaviors) { - for(QFieldMetaData field : fields) + if(!alreadyAppliedFieldDisplayBehaviors) { - if(record.getDisplayValue(field.getName()) == null) + if(table != null) { - String formattedValue = formatValue(field, record.getValue(field.getName())); - record.setDisplayValue(field.getName(), formattedValue); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, QContext.getQInstance(), table, List.of(record), null); } } - } - - - /******************************************************************************* - ** For a list of records, set their display values - *******************************************************************************/ - public static void setDisplayValuesInRecord(Map fields, QRecord record) - { for(Map.Entry entry : fields.entrySet()) { String fieldName = entry.getKey(); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java index 778df8f2..0338e34b 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplier.java @@ -27,6 +27,7 @@ import java.util.Set; 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.FieldBehavior; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldDisplayBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; @@ -44,7 +45,8 @@ public class ValueBehaviorApplier public enum Action { INSERT, - UPDATE + UPDATE, + FORMATTING } @@ -63,7 +65,34 @@ public class ValueBehaviorApplier { for(FieldBehavior fieldBehavior : CollectionUtils.nonNullCollection(field.getBehaviors())) { - fieldBehavior.apply(action, recordList, instance, table, field, behaviorsToOmit); + boolean applyBehavior = true; + if(behaviorsToOmit != null && behaviorsToOmit.contains(fieldBehavior)) + { + ///////////////////////////////////////////////////////////////////////////////////////// + // if we're given a set of behaviors to omit, and this behavior is in there, then skip // + ///////////////////////////////////////////////////////////////////////////////////////// + applyBehavior = false; + } + + if(Action.FORMATTING == action && !(fieldBehavior instanceof FieldDisplayBehavior)) + { + //////////////////////////////////////////////////////////////////////////////////////////////// + // for the formatting action, do not apply the behavior unless it is a field-display-behavior // + //////////////////////////////////////////////////////////////////////////////////////////////// + applyBehavior = false; + } + else if(Action.FORMATTING != action && fieldBehavior instanceof FieldDisplayBehavior) + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // for non-formatting actions, do not apply the behavior IF it is a field-display-behavior // + ///////////////////////////////////////////////////////////////////////////////////////////// + applyBehavior = false; + } + + if(applyBehavior) + { + fieldBehavior.apply(action, recordList, instance, table, field); + } } } } 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 ff86861f..85c7e307 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 @@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.ParentWidgetMetaD import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface; import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; @@ -810,7 +811,7 @@ public class QInstanceValidator /******************************************************************************* ** *******************************************************************************/ - private void validateTableField(QInstance qInstance, String tableName, String fieldName, QTableMetaData table, QFieldMetaData field) + private > void validateTableField(QInstance qInstance, String tableName, String fieldName, QTableMetaData table, QFieldMetaData field) { assertCondition(Objects.equals(fieldName, field.getName()), "Inconsistent naming in table " + tableName + " for field " + fieldName + "/" + field.getName() + "."); @@ -823,12 +824,32 @@ public class QInstanceValidator String prefix = "Field " + fieldName + " in table " + tableName + " "; + /////////////////////////////////////////////////// + // validate things we know about field behaviors // + /////////////////////////////////////////////////// ValueTooLongBehavior behavior = field.getBehaviorOrDefault(qInstance, ValueTooLongBehavior.class); if(behavior != null && !behavior.equals(ValueTooLongBehavior.PASS_THROUGH)) { assertCondition(field.getMaxLength() != null, prefix + "specifies a ValueTooLongBehavior, but not a maxLength."); } + Set>> usedFieldBehaviorTypes = new HashSet<>(); + if(field.getBehaviors() != null) + { + for(FieldBehavior fieldBehavior : field.getBehaviors()) + { + Class> behaviorClass = (Class>) fieldBehavior.getClass(); + + errors.addAll(fieldBehavior.validateBehaviorConfiguration(table, field)); + + if(!fieldBehavior.allowMultipleBehaviorsOfThisType()) + { + assertCondition(!usedFieldBehaviorTypes.contains(behaviorClass), prefix + "has more than 1 fieldBehavior of type " + behaviorClass.getSimpleName() + ", which is not allowed for this type"); + } + usedFieldBehaviorTypes.add(behaviorClass); + } + } + if(field.getMaxLength() != null) { assertCondition(field.getMaxLength() > 0, prefix + "has an invalid maxLength (" + field.getMaxLength() + ") - must be greater than 0."); @@ -1449,7 +1470,7 @@ public class QInstanceValidator private void validateScheduleMetaData(QScheduleMetaData schedule, QInstance qInstance, String prefix) { boolean isRepeat = schedule.getRepeatMillis() != null || schedule.getRepeatSeconds() != null; - boolean isCron = StringUtils.hasContent(schedule.getCronExpression()); + boolean isCron = StringUtils.hasContent(schedule.getCronExpression()); assertCondition(isRepeat || isCron, prefix + " either repeatMillis or repeatSeconds or cronExpression must be set"); assertCondition(!(isRepeat && isCron), prefix + " both a repeat time and cronExpression may not be set"); @@ -1469,8 +1490,8 @@ public class QInstanceValidator if(assertCondition(StringUtils.hasContent(schedule.getCronTimeZoneId()), prefix + " a cron schedule must specify a cronTimeZoneId")) { - String[] availableIDs = TimeZone.getAvailableIDs(); - Optional first = Arrays.stream(availableIDs).filter(id -> id.equals(schedule.getCronTimeZoneId())).findFirst(); + String[] availableIDs = TimeZone.getAvailableIDs(); + Optional first = Arrays.stream(availableIDs).filter(id -> id.equals(schedule.getCronTimeZoneId())).findFirst(); assertCondition(first.isPresent(), prefix + " unrecognized cronTimeZoneId: " + schedule.getCronTimeZoneId()); } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java index 33add9cd..6583e7c4 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/FieldValueListData.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter; import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -147,7 +148,7 @@ public class FieldValueListData extends QWidgetData } } - QValueFormatter.setDisplayValuesInRecord(fields, record); + QValueFormatter.setDisplayValuesInRecord(null, fields.stream().collect(Collectors.toMap(f -> f.getName(), f -> f)), record); } 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 b6ea0975..e09673a3 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 @@ -150,7 +150,7 @@ public class MetaDataProducerHelper } catch(Exception e) { - LOG.warn("error executing metaDataProducer", logPair("producer", producer.getClass().getSimpleName()), e); + LOG.warn("error executing metaDataProducer", e, logPair("producer", producer.getClass().getSimpleName())); } } else diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehavior.java new file mode 100644 index 00000000..5345a53d --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehavior.java @@ -0,0 +1,331 @@ +/* + * 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.model.metadata.fields; + + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter; +import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; +import com.kingsrook.qqq.backend.core.logging.QLogger; +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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Field Display Behavior class for customizing the display values used + ** in date-time fields + *******************************************************************************/ +public class DateTimeDisplayValueBehavior implements FieldDisplayBehavior +{ + private static final QLogger LOG = QLogger.getLogger(DateTimeDisplayValueBehavior.class); + + private String zoneIdFromFieldName; + private String fallbackZoneId; + + private String defaultZoneId; + + private static DateTimeDisplayValueBehavior NOOP = new DateTimeDisplayValueBehavior(); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public DateTimeDisplayValueBehavior getDefault() + { + return NOOP; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field) + { + if(StringUtils.hasContent(defaultZoneId)) + { + applyDefaultZoneId(recordList, table, field); + } + else if(StringUtils.hasContent(zoneIdFromFieldName)) + { + applyZoneIdFromFieldName(recordList, table, field); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void applyDefaultZoneId(List recordList, QTableMetaData table, QFieldMetaData field) + { + for(QRecord record : CollectionUtils.nonNullList(recordList)) + { + try + { + Instant instant = record.getValueInstant(field.getName()); + ZonedDateTime zonedDateTime = instant.atZone(ZoneId.of(defaultZoneId)); + record.setDisplayValue(field.getName(), QValueFormatter.formatDateTimeWithZone(zonedDateTime)); + } + catch(Exception e) + { + LOG.info("Error applying defaultZoneId DateTimeDisplayValueBehavior", logPair("table", table.getName()), logPair("field", field.getName()), logPair("id", record.getValue(table.getPrimaryKeyField()))); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void applyZoneIdFromFieldName(List recordList, QTableMetaData table, QFieldMetaData field) + { + for(QRecord record : CollectionUtils.nonNullList(recordList)) + { + try + { + Instant instant = record.getValueInstant(field.getName()); + String zoneString = record.getValueString(zoneIdFromFieldName); + + ZoneId zoneId; + try + { + zoneId = ZoneId.of(zoneString); + } + catch(Exception e) + { + //////////////////////////////////////////////////////////////////////////////////////////////// + // if the zone string from the other field isn't valid, and we have a fallback, try to use it // + //////////////////////////////////////////////////////////////////////////////////////////////// + if(StringUtils.hasContent(fallbackZoneId)) + { + zoneId = ZoneId.of(fallbackZoneId); + } + else + { + throw (e); + } + } + + ZonedDateTime zonedDateTime = instant.atZone(zoneId); + record.setDisplayValue(field.getName(), QValueFormatter.formatDateTimeWithZone(zonedDateTime)); + } + catch(Exception e) + { + LOG.info("Error applying zoneIdFromFieldName DateTimeDisplayValueBehavior", e, logPair("table", table.getName()), logPair("field", field.getName()), logPair("id", record.getValue(table.getPrimaryKeyField()))); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List validateBehaviorConfiguration(QTableMetaData tableMetaData, QFieldMetaData fieldMetaData) + { + List errors = new ArrayList<>(); + String errorSuffix = " field [" + fieldMetaData.getName() + "] in table [" + tableMetaData.getName() + "]"; + + if(!QFieldType.DATE_TIME.equals(fieldMetaData.getType())) + { + errors.add("A DateTimeDisplayValueBehavior was a applied to a non-DATE_TIME" + errorSuffix); + } + + ////////////////////////////////////////////////// + // validate rules if zoneIdFromFieldName is set // + ////////////////////////////////////////////////// + if(StringUtils.hasContent(zoneIdFromFieldName)) + { + if(StringUtils.hasContent(defaultZoneId)) + { + errors.add("You may not specify both zoneIdFromFieldName and defaultZoneId in DateTimeDisplayValueBehavior on" + errorSuffix); + } + + if(!tableMetaData.getFields().containsKey(zoneIdFromFieldName)) + { + errors.add("Unrecognized field name [" + zoneIdFromFieldName + "] for [zoneIdFromFieldName] in DateTimeDisplayValueBehavior on" + errorSuffix); + } + else + { + QFieldMetaData zoneIdField = tableMetaData.getFields().get(zoneIdFromFieldName); + if(!QFieldType.STRING.equals(zoneIdField.getType())) + { + errors.add("A non-STRING type [" + zoneIdField.getType() + "] was specified as the zoneIdFromFieldName field [" + zoneIdFromFieldName + "] in DateTimeDisplayValueBehavior on" + errorSuffix); + } + } + } + + //////////////////////////////////////////// + // validate rules if defaultZoneId is set // + //////////////////////////////////////////// + if(StringUtils.hasContent(defaultZoneId)) + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // would check that you didn't specify from zoneIdFromFieldName - but that's covered above // + ///////////////////////////////////////////////////////////////////////////////////////////// + + if(StringUtils.hasContent(fallbackZoneId)) + { + errors.add("You may not specify both defaultZoneId and fallbackZoneId in DateTimeDisplayValueBehavior on" + errorSuffix); + } + + try + { + ZoneId.of(defaultZoneId); + } + catch(Exception e) + { + errors.add("Invalid ZoneId [" + defaultZoneId + "] for [defaultZoneId] in DateTimeDisplayValueBehavior on" + errorSuffix + "; " + e.getMessage()); + } + } + + ///////////////////////////////////////////// + // validate rules if fallbackZoneId is set // + ///////////////////////////////////////////// + if(StringUtils.hasContent(fallbackZoneId)) + { + if(!StringUtils.hasContent(zoneIdFromFieldName)) + { + errors.add("You may only set fallbackZoneId if using zoneIdFromFieldName in DateTimeDisplayValueBehavior on" + errorSuffix); + } + + try + { + ZoneId.of(fallbackZoneId); + } + catch(Exception e) + { + errors.add("Invalid ZoneId [" + fallbackZoneId + "] for [fallbackZoneId] in DateTimeDisplayValueBehavior on" + errorSuffix + "; " + e.getMessage()); + } + } + + return (errors); + } + + + + /******************************************************************************* + ** Getter for zoneIdFromFieldName + *******************************************************************************/ + public String getZoneIdFromFieldName() + { + return (this.zoneIdFromFieldName); + } + + + + /******************************************************************************* + ** Setter for zoneIdFromFieldName + *******************************************************************************/ + public void setZoneIdFromFieldName(String zoneIdFromFieldName) + { + this.zoneIdFromFieldName = zoneIdFromFieldName; + } + + + + /******************************************************************************* + ** Fluent setter for zoneIdFromFieldName + *******************************************************************************/ + public DateTimeDisplayValueBehavior withZoneIdFromFieldName(String zoneIdFromFieldName) + { + this.zoneIdFromFieldName = zoneIdFromFieldName; + return (this); + } + + + + /******************************************************************************* + ** Getter for defaultZoneId + *******************************************************************************/ + public String getDefaultZoneId() + { + return (this.defaultZoneId); + } + + + + /******************************************************************************* + ** Setter for defaultZoneId + *******************************************************************************/ + public void setDefaultZoneId(String defaultZoneId) + { + this.defaultZoneId = defaultZoneId; + } + + + + /******************************************************************************* + ** Fluent setter for defaultZoneId + *******************************************************************************/ + public DateTimeDisplayValueBehavior withDefaultZoneId(String defaultZoneId) + { + this.defaultZoneId = defaultZoneId; + return (this); + } + + + + /******************************************************************************* + ** Getter for fallbackZoneId + *******************************************************************************/ + public String getFallbackZoneId() + { + return (this.fallbackZoneId); + } + + + + /******************************************************************************* + ** Setter for fallbackZoneId + *******************************************************************************/ + public void setFallbackZoneId(String fallbackZoneId) + { + this.fallbackZoneId = fallbackZoneId; + } + + + + /******************************************************************************* + ** Fluent setter for fallbackZoneId + *******************************************************************************/ + public DateTimeDisplayValueBehavior withFallbackZoneId(String fallbackZoneId) + { + this.fallbackZoneId = fallbackZoneId; + return (this); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java index 9000598b..dbf47e6d 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DynamicDefaultValueBehavior.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * 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/ @@ -26,7 +26,6 @@ import java.io.Serializable; import java.time.Instant; import java.time.LocalDate; import java.util.List; -import java.util.Set; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -70,16 +69,12 @@ public enum DynamicDefaultValueBehavior implements FieldBehavior recordList, QInstance instance, QTableMetaData table, QFieldMetaData field, Set> behaviorsToOmit) + public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field) { if(this.equals(NONE)) { return; } - if(behaviorsToOmit != null && behaviorsToOmit.contains(this)) - { - return; - } switch(this) { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java index 215843df..6b2d92bf 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldBehavior.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * 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/ @@ -22,8 +22,9 @@ package com.kingsrook.qqq.backend.core.model.metadata.fields; +import java.util.Collections; import java.util.List; -import java.util.Set; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -34,8 +35,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; ** Interface for (expected to be?) enums which define behaviors that get applied ** to fields. ** - ** At the present, these behaviors get applied before a field is stored (insert - ** or update), through the ValueBehaviorApplier class. + ** Some of these behaviors get applied before a field is stored (insert + ** or update), through the ValueBehaviorApplier class. Others can be used to + ** do more advanced display formatting than the displayFormat string alone can + ** do (see QValueFormatter). ** *******************************************************************************/ public interface FieldBehavior> @@ -45,12 +48,13 @@ public interface FieldBehavior> ** In case a behavior of this type wasn't set on the field, what should the ** default of this type be? *******************************************************************************/ + @JsonIgnore T getDefault(); /******************************************************************************* ** Apply this behavior to a list of records *******************************************************************************/ - void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field, Set> behaviorsToOmit); + void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field); /******************************************************************************* ** control if multiple behaviors of this type should be allowed together on a field. @@ -60,4 +64,14 @@ public interface FieldBehavior> return (false); } + /******************************************************************************* + ** allow this behavior to be validated during QInstance validation. + ** + ** return a list of validation errors, if there are any. + *******************************************************************************/ + default List validateBehaviorConfiguration(QTableMetaData tableMetaData, QFieldMetaData fieldMetaData) + { + return (Collections.emptyList()); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldDisplayBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldDisplayBehavior.java new file mode 100644 index 00000000..c5150557 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldDisplayBehavior.java @@ -0,0 +1,31 @@ +/* + * 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.model.metadata.fields; + + +/******************************************************************************* + ** + *******************************************************************************/ +public interface FieldDisplayBehavior> extends FieldBehavior +{ + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java index a1d7ea4d..d78d469d 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/QFieldMetaData.java @@ -721,6 +721,17 @@ public class QFieldMetaData implements Cloneable { return (behaviorType.getEnumConstants()[0].getDefault()); } + else + { + try + { + return (behaviorType.getConstructor().newInstance().getDefault()); + } + catch(Exception e) + { + LOG.warn("Error getting default behaviorType for [" + behaviorType.getSimpleName() + "]", e); + } + } return (null); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java index 77cd24cd..f439f7e6 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/ValueTooLongBehavior.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * 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/ @@ -23,7 +23,6 @@ package com.kingsrook.qqq.backend.core.model.metadata.fields; import java.util.List; -import java.util.Set; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -66,16 +65,12 @@ public enum ValueTooLongBehavior implements FieldBehavior ** *******************************************************************************/ @Override - public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field, Set> behaviorsToOmit) + public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field) { if(this.equals(PASS_THROUGH)) { return; } - if(behaviorsToOmit != null && behaviorsToOmit.contains(this)) - { - return; - } String fieldName = field.getName(); if(!QFieldType.STRING.equals(field.getType())) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/QBackendModuleDispatcher.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/QBackendModuleDispatcher.java index a4e8525e..2b218bd6 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/QBackendModuleDispatcher.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/QBackendModuleDispatcher.java @@ -27,6 +27,7 @@ import java.util.Map; import com.kingsrook.qqq.backend.core.exceptions.QModuleDispatchException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -89,7 +90,7 @@ public class QBackendModuleDispatcher } catch(Exception e) { - LOG.debug("Backend module [{}] could not be loaded: {}", moduleClassName, e.getMessage()); + LOG.debug("Backend module could not be loaded", e, logPair("moduleClassName", moduleClassName)); } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java index 39dbd7f1..21584e8e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/columnstats/ColumnStatsStep.java @@ -34,6 +34,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.DateTimeGroupBy; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; @@ -252,7 +253,7 @@ public class ColumnStatsStep implements BackendStep QPossibleValueTranslator qPossibleValueTranslator = new QPossibleValueTranslator(); qPossibleValueTranslator.translatePossibleValuesInRecords(table, valueCounts, queryJoin == null ? null : List.of(queryJoin), null); - QValueFormatter.setDisplayValuesInRecords(Map.of(fieldName, field, "count", countField), valueCounts); + QValueFormatter.setDisplayValuesInRecords(table, Map.of(fieldName, field, "count", countField), valueCounts); runBackendStepOutput.addValue("valueCounts", valueCounts); @@ -442,13 +443,13 @@ public class ColumnStatsStep implements BackendStep } QFieldMetaData percentField = new QFieldMetaData("percent", QFieldType.DECIMAL).withDisplayFormat(DisplayFormat.PERCENT_POINT2).withLabel("Percent"); - QValueFormatter.setDisplayValuesInRecords(Map.of(fieldName, field, "percent", percentField), valueCounts); + QValueFormatter.setDisplayValuesInRecords(table, Map.of(fieldName, field, "percent", percentField), valueCounts); } QInstanceEnricher qInstanceEnricher = new QInstanceEnricher(null); fields.forEach(qInstanceEnricher::enrichField); - QValueFormatter.setDisplayValuesInRecord(fields, statsRecord); + QValueFormatter.setDisplayValuesInRecord(table, fields.stream().collect(Collectors.toMap(f -> f.getName(), f -> f)), statsRecord); runBackendStepOutput.addValue("statsFields", fields); runBackendStepOutput.addValue("statsRecord", statsRecord); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/mock/MockBackendStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/mock/MockBackendStep.java index 3aad6920..b5128b0c 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/mock/MockBackendStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/mock/MockBackendStep.java @@ -56,7 +56,7 @@ public class MockBackendStep implements BackendStep runBackendStepInput.getRecords().forEach(r -> { - LOG.info("We are mocking {}: {}", r.getValueString("firstName"), r.getValue(FIELD_MOCK_VALUE)); + LOG.info("We are mocking " + r.getValueString("firstName") + ": " + r.getValue(FIELD_MOCK_VALUE)); r.setValue(FIELD_MOCK_VALUE, "Ha ha!"); r.setValue("greetingMessage", runBackendStepInput.getValueString(FIELD_GREETING_PREFIX) + " " + r.getValueString("firstName") + " " + runBackendStepInput.getValueString(FIELD_GREETING_SUFFIX)); }); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/AbstractStateKey.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/AbstractStateKey.java index f6fe0e1a..e7a6db09 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/AbstractStateKey.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/AbstractStateKey.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.state; import java.io.Serializable; +import java.time.Instant; /******************************************************************************* @@ -57,4 +58,10 @@ public abstract class AbstractStateKey implements Serializable @Override public abstract String toString(); + /******************************************************************************* + ** Require all state keys to implement the getStartTime method + * + *******************************************************************************/ + public abstract Instant getStartTime(); + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java index 73f54d12..e63a43ad 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProvider.java @@ -23,9 +23,16 @@ package com.kingsrook.qqq.backend.core.state; import java.io.Serializable; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -33,10 +40,16 @@ import java.util.Optional; *******************************************************************************/ public class InMemoryStateProvider implements StateProviderInterface { + private static final QLogger LOG = QLogger.getLogger(InMemoryStateProvider.class); + private static InMemoryStateProvider instance; private final Map map; + private static int jobPeriodSeconds = 60 * 60; // 1 hour + private static int cleanHours = 6; + private static int jobInitialDelay = 60 * 60 * cleanHours; + /******************************************************************************* @@ -45,6 +58,41 @@ public class InMemoryStateProvider implements StateProviderInterface private InMemoryStateProvider() { this.map = new HashMap<>(); + + /////////////////////////////////////////////////////////// + // Start a single thread executor to handle the cleaning // + /////////////////////////////////////////////////////////// + ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleAtFixedRate(new InMemoryStateProvider.InMemoryStateProviderCleanJob(), jobInitialDelay, jobPeriodSeconds, TimeUnit.SECONDS); + } + + + + /******************************************************************************* + ** Runnable that gets scheduled to periodically clean the InMemoryStateProvider + *******************************************************************************/ + private static class InMemoryStateProviderCleanJob implements Runnable + { + private static final QLogger LOG = QLogger.getLogger(InMemoryStateProvider.InMemoryStateProviderCleanJob.class); + + + + /******************************************************************************* + ** run + *******************************************************************************/ + @Override + public void run() + { + try + { + Instant cleanTime = Instant.now().minus(cleanHours, ChronoUnit.HOURS); + getInstance().clean(cleanTime); + } + catch(Exception e) + { + LOG.warn("Error cleaning InMemoryStateProvider entries.", e); + } + } } @@ -101,4 +149,24 @@ public class InMemoryStateProvider implements StateProviderInterface map.remove(key); } + + + /******************************************************************************* + ** Clean entries that started before the given Instant + * + *******************************************************************************/ + @Override + public void clean(Instant cleanBeforeInstant) + { + long jobStartTime = System.currentTimeMillis(); + Integer beforeSize = map.size(); + LOG.info("Starting clean for InMemoryStateProvider.", logPair("beforeSize", beforeSize)); + + map.entrySet().removeIf(e -> e.getKey().getStartTime().isBefore(cleanBeforeInstant)); + + Integer afterSize = map.size(); + long endTime = System.currentTimeMillis(); + LOG.info("Completed clean for InMemoryStateProvider.", logPair("beforeSize", beforeSize), logPair("afterSize", afterSize), logPair("amountCleaned", (beforeSize - afterSize)), logPair("runTimeMillis", (endTime - jobStartTime))); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/SimpleStateKey.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/SimpleStateKey.java index 61f19fdb..7a3fefe1 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/SimpleStateKey.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/SimpleStateKey.java @@ -22,6 +22,9 @@ package com.kingsrook.qqq.backend.core.state; +import java.time.Instant; + + /******************************************************************************* ** *******************************************************************************/ @@ -93,4 +96,17 @@ public class SimpleStateKey extends AbstractStateKey { return key.hashCode(); } + + + + /******************************************************************************* + ** Getter for startTime + *******************************************************************************/ + public Instant getStartTime() + { + ////////////////////////////////////////// + // For now these will never get cleaned // + ////////////////////////////////////////// + return (Instant.now()); + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/StateProviderInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/StateProviderInterface.java index 1c5415bf..c08558d5 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/StateProviderInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/StateProviderInterface.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.state; import java.io.Serializable; +import java.time.Instant; import java.util.Optional; @@ -58,4 +59,8 @@ public interface StateProviderInterface *******************************************************************************/ void remove(AbstractStateKey key); + /******************************************************************************* + ** Clean entries that started before the given Instant + *******************************************************************************/ + void clean(Instant startTime); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/TempFileStateProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/TempFileStateProvider.java index eb60a8f3..77944945 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/TempFileStateProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/TempFileStateProvider.java @@ -27,6 +27,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.Serializable; import java.nio.file.NoSuchFileException; +import java.time.Instant; import java.util.Optional; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.utils.JsonUtils; @@ -127,6 +128,19 @@ public class TempFileStateProvider implements StateProviderInterface + /******************************************************************************* + ** Clean entries that started before the given Instant + *******************************************************************************/ + @Override + public void clean(Instant startTime) + { + //////////////////////////////// + // Not supported at this time // + //////////////////////////////// + } + + + /******************************************************************************* ** Get the file referenced by a key *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/UUIDAndTypeStateKey.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/UUIDAndTypeStateKey.java index 38659ccc..24486be6 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/UUIDAndTypeStateKey.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/state/UUIDAndTypeStateKey.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.state; import java.io.Serializable; +import java.time.Instant; import java.util.Objects; import java.util.UUID; @@ -34,6 +35,7 @@ public class UUIDAndTypeStateKey extends AbstractStateKey implements Serializabl { private final UUID uuid; private final StateType stateType; + private final Instant startTime; @@ -43,7 +45,7 @@ public class UUIDAndTypeStateKey extends AbstractStateKey implements Serializabl *******************************************************************************/ public UUIDAndTypeStateKey(StateType stateType) { - this(UUID.randomUUID(), stateType); + this(UUID.randomUUID(), stateType, Instant.now()); } @@ -53,9 +55,21 @@ public class UUIDAndTypeStateKey extends AbstractStateKey implements Serializabl ** *******************************************************************************/ public UUIDAndTypeStateKey(UUID uuid, StateType stateType) + { + this(uuid, stateType, Instant.now()); + } + + + + /******************************************************************************* + ** Constructor where user can supply the UUID. + ** + *******************************************************************************/ + public UUIDAndTypeStateKey(UUID uuid, StateType stateType, Instant startTime) { this.uuid = uuid; this.stateType = stateType; + this.startTime = startTime; } @@ -133,4 +147,15 @@ public class UUIDAndTypeStateKey extends AbstractStateKey implements Serializabl { return "{uuid=" + uuid + ", stateType=" + stateType + '}'; } + + + + /******************************************************************************* + ** Getter for startTime + *******************************************************************************/ + public Instant getStartTime() + { + return (this.startTime); + } + } diff --git a/qqq-backend-core/src/main/resources/log4j2.xml b/qqq-backend-core/src/main/resources/log4j2.xml index ffb3baa6..8883de80 100644 --- a/qqq-backend-core/src/main/resources/log4j2.xml +++ b/qqq-backend-core/src/main/resources/log4j2.xml @@ -31,6 +31,8 @@ + + diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java index 893c2a41..8760b0fb 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.actions.values; import java.math.BigDecimal; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -32,10 +33,13 @@ import java.time.ZonedDateTime; import java.util.Collections; 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.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat; 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.fields.DateTimeDisplayValueBehavior; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.TestUtils; import org.junit.jupiter.api.Test; @@ -210,4 +214,23 @@ class QValueFormatterTest extends BaseTest assertEquals("2023-02-01 07:15:47 PM CST", QValueFormatter.formatDateTimeWithZone(ZonedDateTime.of(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15, 47), ZoneId.of("US/Central")))); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldDisplayBehaviors() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + + table.withField(new QFieldMetaData("timeZone", QFieldType.STRING)); + table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withZoneIdFromFieldName("timeZone")); + + QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z")).withValue("timeZone", "America/Chicago"); + QValueFormatter.setDisplayValuesInRecords(table, List.of(record)); + assertEquals("2024-04-04 02:12:00 PM CDT", record.getDisplayValue("createDate")); + } + } \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java index 96f9d297..65c40b22 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/ValueBehaviorApplierTest.java @@ -29,9 +29,12 @@ import com.kingsrook.qqq.backend.core.BaseTest; 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.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldDisplayBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.TestUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -140,6 +143,36 @@ class ValueBehaviorApplierTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testApplyFormattingBehaviors() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + + table.getField("firstName").withBehavior(ToUpperCaseBehavior.getInstance()); + table.getField("lastName").withBehavior(ToUpperCaseBehavior.NOOP); + table.getField("ssn").withBehavior(ValueTooLongBehavior.TRUNCATE).withMaxLength(1); + + QRecord record = new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("ssn", "0123456789"); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null); + + assertEquals("HOMER", record.getDisplayValue("firstName")); + assertNull(record.getDisplayValue("lastName")); // noop will literally do nothing, not even pass value through. + assertEquals("0123456789", record.getValueString("ssn")); // formatting action should not run the too-long truncate behavior + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // now put to-upper-case behavior on lastName, but run INSERT actions - and make sure it doesn't get applied. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + table.getField("lastName").withBehavior(ToUpperCaseBehavior.getInstance()); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.INSERT, qInstance, table, List.of(record), null); + assertNull(record.getDisplayValue("lastName")); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -153,4 +186,73 @@ class ValueBehaviorApplierTest extends BaseTest return (recordOpt.get()); } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static class ToUpperCaseBehavior implements FieldDisplayBehavior + { + private final boolean enabled; + + private static ToUpperCaseBehavior NOOP = new ToUpperCaseBehavior(false); + private static ToUpperCaseBehavior instance = new ToUpperCaseBehavior(true); + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + private ToUpperCaseBehavior(boolean enabled) + { + this.enabled = enabled; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public ToUpperCaseBehavior getDefault() + { + return (NOOP); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static ToUpperCaseBehavior getInstance() + { + return (instance); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void apply(ValueBehaviorApplier.Action action, List recordList, QInstance instance, QTableMetaData table, QFieldMetaData field) + { + if(!enabled) + { + return; + } + + for(QRecord record : CollectionUtils.nonNullList(recordList)) + { + String displayValue = record.getValueString(field.getName()); + if(displayValue != null) + { + displayValue = displayValue.toUpperCase(); + } + + record.setDisplayValue(field.getName(), displayValue); + } + } + } } 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 a98773c9..e0eec66e 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 @@ -26,6 +26,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Set; +import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -56,6 +58,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; 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.fields.DateTimeDisplayValueBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; @@ -1758,6 +1761,26 @@ public class QInstanceValidatorTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldBehaviors() + { + BiFunction fieldExtractor = (QInstance qInstance, String fieldName) -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).getField(fieldName); + assertValidationFailureReasons((qInstance -> fieldExtractor.apply(qInstance, "firstName").withBehaviors(Set.of(ValueTooLongBehavior.ERROR, ValueTooLongBehavior.TRUNCATE)).withMaxLength(1)), + "more than 1 fieldBehavior of type ValueTooLongBehavior, which is not allowed"); + + /////////////////////////////////////////////////////////////////////////// + // make sure a custom validation method in a field behavior gets applied // + // more tests for this particular behavior are in its own test class // + /////////////////////////////////////////////////////////////////////////// + assertValidationFailureReasons((qInstance -> fieldExtractor.apply(qInstance, "firstName").withBehavior(new DateTimeDisplayValueBehavior())), + "DateTimeDisplayValueBehavior was a applied to a non-DATE_TIME field"); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehaviorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehaviorTest.java new file mode 100644 index 00000000..ca31fdc3 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/fields/DateTimeDisplayValueBehaviorTest.java @@ -0,0 +1,169 @@ +/* + * 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.model.metadata.fields; + + +import java.time.Instant; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; +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.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for DateTimeDisplayValueBehavior + *******************************************************************************/ +class DateTimeDisplayValueBehaviorTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testZoneIdFromFieldName() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + + table.withField(new QFieldMetaData("timeZone", QFieldType.STRING)); + table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withZoneIdFromFieldName("timeZone")); + + QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z")).withValue("timeZone", "America/Chicago"); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null); + assertEquals("2024-04-04 02:12:00 PM CDT", record.getDisplayValue("createDate")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testZoneIdFromFieldNameWithFallback() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + + table.withField(new QFieldMetaData("timeZone", QFieldType.STRING)); + table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withZoneIdFromFieldName("timeZone").withFallbackZoneId("America/Denver")); + + QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z")).withValue("timeZone", "whodis"); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null); + assertEquals("2024-04-04 01:12:00 PM MDT", record.getDisplayValue("createDate")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDefaultZoneId() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + + table.withField(new QFieldMetaData("timeZone", QFieldType.STRING)); + table.getField("createDate").withBehavior(new DateTimeDisplayValueBehavior().withDefaultZoneId("America/Los_Angeles")); + + QRecord record = new QRecord().withValue("createDate", Instant.parse("2024-04-04T19:12:00Z")); + ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.FORMATTING, qInstance, table, List.of(record), null); + assertEquals("2024-04-04 12:12:00 PM PDT", record.getDisplayValue("createDate")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidation() + { + QInstance qInstance = QContext.getQInstance(); + QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY); + QFieldMetaData field = table.getField("createDate"); + table.withField(new QFieldMetaData("timeZone", QFieldType.STRING)); + + Function, List> testOne = setup -> + { + DateTimeDisplayValueBehavior dateTimeDisplayValueBehavior = new DateTimeDisplayValueBehavior(); + setup.accept(dateTimeDisplayValueBehavior); + return (dateTimeDisplayValueBehavior.validateBehaviorConfiguration(table, field)); + }; + + /////////////////// + // valid configs // + /////////////////// + assertThat(testOne.apply(b -> b.toString())).isEmpty(); // default setup (noop use-case) is valid + assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone"))).isEmpty(); + assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withFallbackZoneId("UTC"))).isEmpty(); + assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withFallbackZoneId("America/Chicago"))).isEmpty(); + assertThat(testOne.apply(b -> b.withDefaultZoneId("UTC"))).isEmpty(); + assertThat(testOne.apply(b -> b.withDefaultZoneId("America/Chicago"))).isEmpty(); + + ///////////////////// + // invalid configs // + ///////////////////// + assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("notAField"))) + .hasSize(1).first().asString() + .contains("Unrecognized field name"); + + assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("id"))) + .hasSize(1).first().asString() + .contains("A non-STRING type [INTEGER] was specified as the zoneIdFromFieldName field"); + + assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withDefaultZoneId("UTC"))) + .hasSize(1).first().asString() + .contains("You may not specify both zoneIdFromFieldName and defaultZoneId"); + + assertThat(testOne.apply(b -> b.withDefaultZoneId("UTC").withFallbackZoneId("UTC"))) + .hasSize(2) + .anyMatch(s -> s.contains("You may not specify both defaultZoneId and fallbackZoneId")) + .anyMatch(s -> s.contains("You may only set fallbackZoneId if using zoneIdFromFieldName")); + + assertThat(testOne.apply(b -> b.withFallbackZoneId("UTC"))) + .hasSize(1).first().asString() + .contains("You may only set fallbackZoneId if using zoneIdFromFieldName"); + + assertThat(testOne.apply(b -> b.withDefaultZoneId("notAZone"))) + .hasSize(1).first().asString() + .contains("Invalid ZoneId [notAZone] for [defaultZoneId]"); + + assertThat(testOne.apply(b -> b.withZoneIdFromFieldName("timeZone").withFallbackZoneId("notAZone"))) + .hasSize(1).first().asString() + .contains("Invalid ZoneId [notAZone] for [fallbackZoneId]"); + + assertThat(new DateTimeDisplayValueBehavior().validateBehaviorConfiguration(table, table.getField("firstName"))) + .hasSize(1).first().asString() + .contains("non-DATE_TIME field [firstName]"); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProviderTest.java index f1dccee9..b8492fe8 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProviderTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/state/InMemoryStateProviderTest.java @@ -22,6 +22,8 @@ package com.kingsrook.qqq.backend.core.state; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.UUID; import com.kingsrook.qqq.backend.core.BaseTest; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -88,4 +90,42 @@ public class InMemoryStateProviderTest extends BaseTest }); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + public void testClean() + { + InMemoryStateProvider stateProvider = InMemoryStateProvider.getInstance(); + + ///////////////////////////////////////////////////////////// + // Add an entry that is 3 hours old, should not be cleaned // + ///////////////////////////////////////////////////////////// + UUIDAndTypeStateKey newKey = new UUIDAndTypeStateKey(UUID.randomUUID(), StateType.PROCESS_STATUS, Instant.now().minus(3, ChronoUnit.HOURS)); + String newUUID = UUID.randomUUID().toString(); + QRecord newQRecord = new QRecord().withValue("uuid", newUUID); + stateProvider.put(newKey, newQRecord); + + //////////////////////////////////////////////////////////// + // Add an entry that is 5 hours old, it should be cleaned // + //////////////////////////////////////////////////////////// + UUIDAndTypeStateKey oldKey = new UUIDAndTypeStateKey(UUID.randomUUID(), StateType.PROCESS_STATUS, Instant.now().minus(5, ChronoUnit.HOURS)); + String oldUUID = UUID.randomUUID().toString(); + QRecord oldQRecord = new QRecord().withValue("uuid", oldUUID); + stateProvider.put(oldKey, oldQRecord); + + /////////////////// + // Call to clean // + /////////////////// + stateProvider.clean(Instant.now().minus(4, ChronoUnit.HOURS)); + + QRecord qRecordFromState = stateProvider.get(QRecord.class, newKey).get(); + Assertions.assertEquals(newUUID, qRecordFromState.getValueString("uuid"), "Should read value from state persistence"); + + Assertions.assertTrue(stateProvider.get(QRecord.class, oldKey).isEmpty(), "Key not found in state should return empty"); + + } + } \ No newline at end of file diff --git a/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/AbstractFilesystemAction.java b/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/AbstractFilesystemAction.java index df687e90..c9fb807f 100644 --- a/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/AbstractFilesystemAction.java +++ b/qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/local/actions/AbstractFilesystemAction.java @@ -49,6 +49,7 @@ import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.AbstractF import com.kingsrook.qqq.backend.module.filesystem.base.utils.SharedFilesystemBackendModuleUtils; import com.kingsrook.qqq.backend.module.filesystem.exceptions.FilesystemException; import org.apache.commons.io.FileUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -183,7 +184,7 @@ public class AbstractFilesystemAction extends AbstractBaseFilesystemAction // if the file doesn't exist, just exit with noop. don't throw an error - that should only // // happen if the "contract" of the method is broken, and the file still exists // ////////////////////////////////////////////////////////////////////////////////////////////// - LOG.debug("Not deleting file [{}], because it does not exist.", file); + LOG.debug("Not deleting file, because it does not exist.", logPair("file", file)); return; } @@ -218,7 +219,7 @@ public class AbstractFilesystemAction extends AbstractBaseFilesystemAction ////////////////////////////////////////////////////////////////////////////////////// if(!destinationParent.exists()) { - LOG.debug("Making destination directory {} for move", destinationParent.getAbsolutePath()); + LOG.debug("Making destination directory for move", logPair("directory", destinationParent.getAbsolutePath())); if(!destinationParent.mkdirs()) { throw (new FilesystemException("Failed to make destination directory " + destinationParent.getAbsolutePath() + " to move " + source + " into.")); diff --git a/qqq-middleware-slack/src/main/java/com/kingsrook/qqq/slack/QSlackImplementation.java b/qqq-middleware-slack/src/main/java/com/kingsrook/qqq/slack/QSlackImplementation.java index 25185398..850491cc 100644 --- a/qqq-middleware-slack/src/main/java/com/kingsrook/qqq/slack/QSlackImplementation.java +++ b/qqq-middleware-slack/src/main/java/com/kingsrook/qqq/slack/QSlackImplementation.java @@ -666,11 +666,11 @@ public class QSlackImplementation ////////////////////////////////////////////////////////////////////////// // Print result, which includes information about the message (like TS) // ////////////////////////////////////////////////////////////////////////// - LOG.info("Slack post result {}", result); + LOG.info("Slack post result: " + result); } catch(IOException | SlackApiException e) { - LOG.error("error: {}", e.getMessage(), e); + LOG.error("error", e); } }