From 81248a8dafc96640b6c1e9d41ae2332a10622572 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 23 Aug 2024 09:57:08 -0500 Subject: [PATCH 1/9] CE-1646 Accept 'useCase' parameter in possibleValues function, to pass to backend, to control how possible-value filters are applied when input values are missing --- .../CriteriaMissingInputValueBehavior.java | 66 +++++ .../actions/tables/query/FilterUseCase.java | 58 +++++ .../actions/tables/query/QQueryFilter.java | 137 ++++++++++- .../PossibleValueSearchFilterUseCase.java | 72 ++++++ .../core/actions/tables/QQueryFilterTest.java | 231 ++++++++++++++++++ .../javalin/QJavalinImplementation.java | 8 +- .../javalin/QJavalinImplementationTest.java | 130 +++++++++- 7 files changed, 685 insertions(+), 17 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaMissingInputValueBehavior.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/FilterUseCase.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/PossibleValueSearchFilterUseCase.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaMissingInputValueBehavior.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaMissingInputValueBehavior.java new file mode 100644 index 00000000..b21d7b57 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaMissingInputValueBehavior.java @@ -0,0 +1,66 @@ +/* + * 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.actions.tables.query; + + +/*************************************************************************** + ** Possible behaviors for doing interpretValues on a filter, and a criteria + ** has a variable value (either as a string-that-looks-like-a-variable, + ** as in ${input.foreignId} for a PVS filter, or a FilterVariableExpression), + ** and a value for that variable isn't available. + ** + ** Used in conjunction with FilterUseCase and its implementations, e.g., + ** PossibleValueSearchFilterUseCase. + ***************************************************************************/ +public enum CriteriaMissingInputValueBehavior +{ + ////////////////////////////////////////////////////////////////////// + // this was the original behavior, before we added this enum. but, // + // it doesn't ever seem entirely valid, and isn't currently used. // + ////////////////////////////////////////////////////////////////////// + INTERPRET_AS_NULL_VALUE, + + ////////////////////////////////////////////////////////////////////////// + // make the criteria behave as though it's not in the filter at all. // + // effectively by changing its operator to TRUE, so it always matches. // + // original intended use is for possible-values on query screens, // + // where a foreign-id isn't present, so we want to show all PV options. // + ////////////////////////////////////////////////////////////////////////// + REMOVE_FROM_FILTER, + + ////////////////////////////////////////////////////////////////////////////////////// + // make the criteria such that it makes no rows ever match. // + // e.g., changes it to a FALSE. I suppose, within an OR, that might // + // not be powerful enough... but, it solves the immediate use-case in // + // front of us, which is forms, where a PV field should show no values // + // until a foreign key field has a value. // + // Note that this use-case used to have the same end-effect by such // + // variables being interpreted as nulls - but this approach feels more intentional. // + ////////////////////////////////////////////////////////////////////////////////////// + MAKE_NO_MATCHES, + + /////////////////////////////////////////////////////////////////////////////////////////// + // throw an exception if a value isn't available. This is the overall default, // + // and originally was what we did for FilterVariableExpressions, e.g., for saved reports // + /////////////////////////////////////////////////////////////////////////////////////////// + THROW_EXCEPTION +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/FilterUseCase.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/FilterUseCase.java new file mode 100644 index 00000000..94ef53fc --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/FilterUseCase.java @@ -0,0 +1,58 @@ +/* + * 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.actions.tables.query; + + +/******************************************************************************* + ** Interface where we can associate behaviors with various use cases for + ** QQueryFilters - the original being, how to handle (in the interpretValues + ** method) how to handle missing input values. + ** + ** Includes a default implementation, with a default behavior - which is to + ** throw an exception upon missing criteria variable values. + *******************************************************************************/ +public interface FilterUseCase +{ + FilterUseCase DEFAULT = new DefaultFilterUseCase(); + + /*************************************************************************** + ** + ***************************************************************************/ + CriteriaMissingInputValueBehavior getDefaultCriteriaMissingInputValueBehavior(); + + + /*************************************************************************** + ** + ***************************************************************************/ + class DefaultFilterUseCase implements FilterUseCase + { + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public CriteriaMissingInputValueBehavior getDefaultCriteriaMissingInputValueBehavior() + { + return CriteriaMissingInputValueBehavior.THROW_EXCEPTION; + } + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java index 6de05ddc..6006d065 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java @@ -528,8 +528,27 @@ public class QQueryFilter implements Serializable, Cloneable ** Note - it may be very important that you call this method on a clone of a ** QQueryFilter - e.g., if it's one that defined in metaData, and that we don't ** want to be (permanently) changed!! - *******************************************************************************/ + ** + ** This overload does not take in a FilterUseCase - it uses FilterUseCase.DEFAULT + ******************************************************************************/ public void interpretValues(Map inputValues) throws QException + { + interpretValues(inputValues, FilterUseCase.DEFAULT); + } + + + + /******************************************************************************* + ** Replace any criteria values that look like ${input.XXX} with the value of XXX + ** from the supplied inputValues map - where the handling of missing values + ** is specified in the inputted FilterUseCase parameter + ** + ** Note - it may be very important that you call this method on a clone of a + ** QQueryFilter - e.g., if it's one that defined in metaData, and that we don't + ** want to be (permanently) changed!! + ** + *******************************************************************************/ + public void interpretValues(Map inputValues, FilterUseCase useCase) throws QException { List caughtExceptions = new ArrayList<>(); @@ -545,6 +564,9 @@ public class QQueryFilter implements Serializable, Cloneable { try { + Serializable interpretedValue = value; + Exception caughtException = null; + if(value instanceof AbstractFilterExpression) { /////////////////////////////////////////////////////////////////////// @@ -553,17 +575,54 @@ public class QQueryFilter implements Serializable, Cloneable /////////////////////////////////////////////////////////////////////// if(value instanceof FilterVariableExpression filterVariableExpression) { - newValues.add(filterVariableExpression.evaluateInputValues(inputValues)); - } - else - { - newValues.add(value); + try + { + interpretedValue = filterVariableExpression.evaluateInputValues(inputValues); + } + catch(Exception e) + { + caughtException = e; + interpretedValue = InputNotFound.instance; + } + } + } + else + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // for non-expressions, cast the value to a string, and see if it can be resolved a variable. // + // there are 3 possible cases here: // + // 1: it doesn't look like a variable, so it just comes back as a string version of whatever went in. // + // 2: it was resolved from a variable to a value, e.g., ${input.someVar} => someValue // + // 3: it looked like a variable, but no value for that variable was present in the interpreter's value // + // map - so we'll get back the InputNotFound.instance. // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + String valueAsString = ValueUtils.getValueAsString(value); + interpretedValue = variableInterpreter.interpretForObject(valueAsString, InputNotFound.instance); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if interpreting a value returned the not-found value, or an empty string, // + // then decide how to handle the missing value, based on the use-case input // + // Note: questionable, using "" here, but that's what reality is passing a lot for cases we want to treat as missing... // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(interpretedValue == InputNotFound.instance || "".equals(interpretedValue)) + { + CriteriaMissingInputValueBehavior missingInputValueBehavior = getMissingInputValueBehavior(useCase); + + switch(missingInputValueBehavior) + { + case REMOVE_FROM_FILTER -> criterion.setOperator(QCriteriaOperator.TRUE); + case MAKE_NO_MATCHES -> criterion.setOperator(QCriteriaOperator.FALSE); + case INTERPRET_AS_NULL_VALUE -> newValues.add(null); + + ///////////////////////////////////////////////// + // handle case in the default: THROW_EXCEPTION // + ///////////////////////////////////////////////// + default -> throw (Objects.requireNonNullElseGet(caughtException, () -> new QUserFacingException("Missing value for criteria on field: " + criterion.getFieldName()))); } } else { - String valueAsString = ValueUtils.getValueAsString(value); - Serializable interpretedValue = variableInterpreter.interpretForObject(valueAsString); newValues.add(interpretedValue); } } @@ -586,6 +645,44 @@ public class QQueryFilter implements Serializable, Cloneable + /*************************************************************************** + ** Note: in the original build of this, it felt like we *might* want to be + ** able to specify these behaviors at the individual criteria level, where + ** the implementation would be to add to QFilterCriteria: + ** - Map missingInputValueBehaviors; + ** - CriteriaMissingInputValueBehavior getMissingInputValueBehaviorForUseCase(FilterUseCase useCase) {} + * + ** (and maybe do that in a sub-class of QFilterCriteria, so it isn't always + ** there? idk...) and then here we'd call: + ** - CriteriaMissingInputValueBehavior missingInputValueBehavior = criterion.getMissingInputValueBehaviorForUseCase(useCase); + * + ** But, we don't actually have that use-case at hand now, so - let's keep it + ** just at the level we need for now. + ** + ***************************************************************************/ + private CriteriaMissingInputValueBehavior getMissingInputValueBehavior(FilterUseCase useCase) + { + if(useCase == null) + { + useCase = FilterUseCase.DEFAULT; + } + + CriteriaMissingInputValueBehavior missingInputValueBehavior = useCase.getDefaultCriteriaMissingInputValueBehavior(); + if(missingInputValueBehavior == null) + { + missingInputValueBehavior = useCase.getDefaultCriteriaMissingInputValueBehavior(); + } + + if(missingInputValueBehavior == null) + { + missingInputValueBehavior = FilterUseCase.DEFAULT.getDefaultCriteriaMissingInputValueBehavior(); + } + + return (missingInputValueBehavior); + } + + + /******************************************************************************* ** Getter for skip *******************************************************************************/ @@ -678,4 +775,28 @@ public class QQueryFilter implements Serializable, Cloneable { return Objects.hash(criteria, orderBys, booleanOperator, subFilters, skip, limit); } + + + + /*************************************************************************** + ** "Token" object to be used as the defaultIfLooksLikeVariableButNotFound + ** parameter to variableInterpreter.interpretForObject, so we can be + ** very clear that we got this default back (e.g., instead of a null, + ** which could maybe mean something else?) + ***************************************************************************/ + private static final class InputNotFound implements Serializable + { + private static InputNotFound instance = new InputNotFound(); + + + + /******************************************************************************* + ** private singleton constructor + *******************************************************************************/ + private InputNotFound() + { + + } + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/PossibleValueSearchFilterUseCase.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/PossibleValueSearchFilterUseCase.java new file mode 100644 index 00000000..8277b067 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/PossibleValueSearchFilterUseCase.java @@ -0,0 +1,72 @@ +/* + * 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.possiblevalues; + + +import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaMissingInputValueBehavior; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.FilterUseCase; + + +/******************************************************************************* + ** FilterUseCase implementation for the ways that possible value searches + ** are performed, and where we want to have different behaviors for criteria + ** that are missing an input value. That is, either for a: + ** + ** - FORM - e.g., creating a new record, or in a process - where we want a + ** missing filter value to basically block you from selecting a value in the + ** PVS field - e.g., you must enter some other foreign-key value before choosing + ** from this possible value - at least that's the use-case we know of now. + ** + ** - FILTER - e.g., a query screen - where there isn't really quite the same + ** scenario of choosing that foreign-key value first - so, such a PVS should + ** list all its values (e.g., a criteria missing an input value should be + ** removed from the filter). + *******************************************************************************/ +public enum PossibleValueSearchFilterUseCase implements FilterUseCase +{ + FORM(CriteriaMissingInputValueBehavior.MAKE_NO_MATCHES), + FILTER(CriteriaMissingInputValueBehavior.REMOVE_FROM_FILTER); + + + private final CriteriaMissingInputValueBehavior defaultCriteriaMissingInputValueBehavior; + + + + /*************************************************************************** + ** + ***************************************************************************/ + PossibleValueSearchFilterUseCase(CriteriaMissingInputValueBehavior defaultCriteriaMissingInputValueBehavior) + { + this.defaultCriteriaMissingInputValueBehavior = defaultCriteriaMissingInputValueBehavior; + } + + + + /******************************************************************************* + ** Getter for defaultCriteriaMissingInputValueBehavior + ** + *******************************************************************************/ + public CriteriaMissingInputValueBehavior getDefaultCriteriaMissingInputValueBehavior() + { + return defaultCriteriaMissingInputValueBehavior; + } +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java index 1257d7e2..0ed7b54f 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java @@ -29,6 +29,8 @@ import java.util.Map; import com.kingsrook.qqq.backend.core.BaseTest; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaMissingInputValueBehavior; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.FilterUseCase; 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.query.expressions.AbstractFilterExpression; @@ -36,9 +38,12 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.Fil import org.junit.jupiter.api.Test; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.BETWEEN; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS; +import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.FALSE; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IS_BLANK; +import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.TRUE; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; /******************************************************************************* @@ -140,4 +145,230 @@ class QQueryFilterTest extends BaseTest assertEquals("joinTableSomeFieldIdEquals", fve7.getVariableName()); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInterpretValueVariableExpressionNotFoundUseCases() throws QException + { + Map inputValues = new HashMap<>(); + + AbstractFilterExpression expression = new FilterVariableExpression() + .withVariableName("clientId"); + + //////////////////////////////////////// + // Control - where the value IS found // + //////////////////////////////////////// + inputValues.put("clientId", 47); + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, expression)); + filter.interpretValues(inputValues); + assertEquals(47, filter.getCriteria().get(0).getValues().get(0)); + assertEquals(EQUALS, filter.getCriteria().get(0).getOperator()); + } + + ////////////////////////////////////////////////////// + // now - remove the value for the next set of cases // + ////////////////////////////////////////////////////// + inputValues.remove("clientId"); + + //////////////////////////////////////////////////////////////////////////////////////////////// + // a use-case that says to remove-from-filter, which, means translate to a criteria of "TRUE" // + //////////////////////////////////////////////////////////////////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, expression)); + filter.interpretValues(inputValues, new RemoveFromFilterUseCase()); + assertEquals(0, filter.getCriteria().get(0).getValues().size()); + assertEquals(TRUE, filter.getCriteria().get(0).getOperator()); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // a use-case that says to make-no-matches, which, means translate to a criteria of "FALSE" // + ////////////////////////////////////////////////////////////////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, expression)); + filter.interpretValues(inputValues, new MakeNoMatchesUseCase()); + assertEquals(0, filter.getCriteria().get(0).getValues().size()); + assertEquals(FALSE, filter.getCriteria().get(0).getOperator()); + } + + /////////////////////////////////////////// + // a use-case that says to treat as null // + /////////////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, expression)); + filter.interpretValues(inputValues, new InterpretAsNullValueUseCase()); + assertNull(filter.getCriteria().get(0).getValues().get(0)); + assertEquals(EQUALS, filter.getCriteria().get(0).getOperator()); + } + + /////////////////////////////////// + // a use-case that says to throw // + /////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, expression)); + assertThatThrownBy(() -> filter.interpretValues(inputValues, new ThrowExceptionUseCase())) + .isInstanceOf(QUserFacingException.class) + .hasMessageContaining("Missing value for variable: clientId"); + } + + ////////////////////////////////////////////////////////// + // verify that empty-string is treated as not-found too // + ////////////////////////////////////////////////////////// + inputValues.put("clientId", ""); + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, expression)); + assertThatThrownBy(() -> filter.interpretValues(inputValues, new ThrowExceptionUseCase())) + .isInstanceOf(QUserFacingException.class) + .hasMessageContaining("Missing value for criteria on field: id"); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInterpretValueStringStyleNotFoundUseCases() throws QException + { + Map inputValues = new HashMap<>(); + + //////////////////////////////////////// + // Control - where the value IS found // + //////////////////////////////////////// + inputValues.put("clientId", 47); + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, "${input.clientId}")); + filter.interpretValues(inputValues); + assertEquals(47, filter.getCriteria().get(0).getValues().get(0)); + assertEquals(EQUALS, filter.getCriteria().get(0).getOperator()); + } + + ////////////////////////////////////////////////////// + // now - remove the value for the next set of cases // + ////////////////////////////////////////////////////// + inputValues.remove("clientId"); + + //////////////////////////////////////////////////////////////////////////////////////////////// + // a use-case that says to remove-from-filter, which, means translate to a criteria of "TRUE" // + //////////////////////////////////////////////////////////////////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, "${input.clientId}")); + filter.interpretValues(inputValues, new RemoveFromFilterUseCase()); + assertEquals(0, filter.getCriteria().get(0).getValues().size()); + assertEquals(TRUE, filter.getCriteria().get(0).getOperator()); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // a use-case that says to make-no-matches, which, means translate to a criteria of "FALSE" // + ////////////////////////////////////////////////////////////////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, "${input.clientId}")); + filter.interpretValues(inputValues, new MakeNoMatchesUseCase()); + assertEquals(0, filter.getCriteria().get(0).getValues().size()); + assertEquals(FALSE, filter.getCriteria().get(0).getOperator()); + } + + /////////////////////////////////////////// + // a use-case that says to treat as null // + /////////////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, "${input.clientId}")); + filter.interpretValues(inputValues, new InterpretAsNullValueUseCase()); + assertNull(filter.getCriteria().get(0).getValues().get(0)); + assertEquals(EQUALS, filter.getCriteria().get(0).getOperator()); + } + + /////////////////////////////////// + // a use-case that says to throw // + /////////////////////////////////// + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, "${input.clientId}")); + assertThatThrownBy(() -> filter.interpretValues(inputValues, new ThrowExceptionUseCase())) + .isInstanceOf(QUserFacingException.class) + .hasMessageContaining("Missing value for criteria on field: id"); + } + + ////////////////////////////////////////////////////////// + // verify that empty-string is treated as not-found too // + ////////////////////////////////////////////////////////// + inputValues.put("clientId", ""); + { + QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, "${input.clientId}")); + assertThatThrownBy(() -> filter.interpretValues(inputValues, new ThrowExceptionUseCase())) + .isInstanceOf(QUserFacingException.class) + .hasMessageContaining("Missing value for criteria on field: id"); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static class RemoveFromFilterUseCase implements FilterUseCase + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public CriteriaMissingInputValueBehavior getDefaultCriteriaMissingInputValueBehavior() + { + return CriteriaMissingInputValueBehavior.REMOVE_FROM_FILTER; + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static class MakeNoMatchesUseCase implements FilterUseCase + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public CriteriaMissingInputValueBehavior getDefaultCriteriaMissingInputValueBehavior() + { + return CriteriaMissingInputValueBehavior.MAKE_NO_MATCHES; + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static class InterpretAsNullValueUseCase implements FilterUseCase + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public CriteriaMissingInputValueBehavior getDefaultCriteriaMissingInputValueBehavior() + { + return CriteriaMissingInputValueBehavior.INTERPRET_AS_NULL_VALUE; + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private static class ThrowExceptionUseCase implements FilterUseCase + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public CriteriaMissingInputValueBehavior getDefaultCriteriaMissingInputValueBehavior() + { + return CriteriaMissingInputValueBehavior.THROW_EXCEPTION; + } + } } diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java index 85254753..33c2b5ad 100644 --- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java @@ -114,6 +114,7 @@ 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.frontend.QFrontendVariant; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; @@ -125,6 +126,7 @@ import com.kingsrook.qqq.backend.core.modules.authentication.implementations.Aut import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.ExceptionUtils; import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.ObjectUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.ValueUtils; import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; @@ -1792,7 +1794,11 @@ public class QJavalinImplementation } defaultQueryFilter = field.getPossibleValueSourceFilter().clone(); - defaultQueryFilter.interpretValues(values); + + String useCaseParam = QJavalinUtils.getQueryParamOrFormParam(context, "useCase"); + PossibleValueSearchFilterUseCase useCase = ObjectUtils.tryElse(() -> PossibleValueSearchFilterUseCase.valueOf(useCaseParam.toUpperCase()), PossibleValueSearchFilterUseCase.FORM); + + defaultQueryFilter.interpretValues(values, useCase); } finishPossibleValuesRequest(context, field.getPossibleValueSourceName(), defaultQueryFilter); diff --git a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java index 1a61710c..9f59d373 100644 --- a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java +++ b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java @@ -950,24 +950,138 @@ class QJavalinImplementationTest extends QJavalinTestBase + /*************************************************************************** + ** + ***************************************************************************/ + private JSONArray assertPossibleValueSuccessfulResponseAndGetOptionsArray(HttpResponse response) + { + assertEquals(200, response.getStatus()); + JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody()); + assertNotNull(jsonObject); + return (jsonObject.getJSONArray("options")); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private void assertPossibleValueSuccessfulResponseWithNoOptions(HttpResponse response) + { + assertEquals(200, response.getStatus()); + JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody()); + assertNotNull(jsonObject); + assertFalse(jsonObject.has("options")); // no results comes back as result w/o options array. + } + + + /******************************************************************************* ** *******************************************************************************/ @Test void testPossibleValueWithFilter() { + ///////////////////////////////////////////////////////////// + // post with no search term, and values that find a result // + ///////////////////////////////////////////////////////////// HttpResponse response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=") .field("values", """ - {"email":"tsamples@mmltholdings.com"} - """) + {"email":"tsamples@mmltholdings.com"} + """) .asString(); - assertEquals(200, response.getStatus()); - JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody()); - assertNotNull(jsonObject); - assertNotNull(jsonObject.getJSONArray("options")); - assertEquals(1, jsonObject.getJSONArray("options").length()); - assertEquals("Tyler Samples (4)", jsonObject.getJSONArray("options").getJSONObject(0).getString("label")); + JSONArray options = assertPossibleValueSuccessfulResponseAndGetOptionsArray(response); + assertNotNull(options); + assertEquals(1, options.length()); + assertEquals("Tyler Samples (4)", options.getJSONObject(0).getString("label")); + + /////////////////////////////////////////////////////////// + // post with search term and values that find no results // + /////////////////////////////////////////////////////////// + response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=notFound") + .field("values", """ + {"email":"tsamples@mmltholdings.com"} + """) + .asString(); + assertPossibleValueSuccessfulResponseWithNoOptions(response); + + //////////////////////////////////////////////////////////////// + // post with no search term, but values that cause no matches // + //////////////////////////////////////////////////////////////// + response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=") + .field("values", """ + {"email":"noUser@mmltholdings.com"} + """) + .asString(); + assertPossibleValueSuccessfulResponseWithNoOptions(response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testPossibleValueWithFilterMissingValue() + { + ///////////////////////////////////////////////////////////// + // filter use-case, with no values, should return options. // + ///////////////////////////////////////////////////////////// + HttpResponse response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=&useCase=filter").asString(); + JSONArray options = assertPossibleValueSuccessfulResponseAndGetOptionsArray(response); + assertNotNull(options); + assertThat(options.length()).isGreaterThanOrEqualTo(5); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // similarly, values map, but not the 'email' value, that this PVS field is based on, should return options. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=&useCase=filter") + .field("values", """ + {"userId":"123"} + """) + .asString(); + options = assertPossibleValueSuccessfulResponseAndGetOptionsArray(response); + assertNotNull(options); + assertThat(options.length()).isGreaterThanOrEqualTo(5); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // similarly, values map, with the email value, but an empty string in there - should act the same as if it's missing, and not filter the values. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=&useCase=filter") + .field("values", """ + {"email":""} + """) + .asString(); + options = assertPossibleValueSuccessfulResponseAndGetOptionsArray(response); + assertNotNull(options); + assertThat(options.length()).isGreaterThanOrEqualTo(5); + + ///////////////////////////////////////////////////////////////////////// + // versus form use-case with no values, which should return 0 options. // + ///////////////////////////////////////////////////////////////////////// + response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=&useCase=form").asString(); + assertPossibleValueSuccessfulResponseWithNoOptions(response); + + ///////////////////////////////////////////////////////////////////////////////// + // versus form use-case with expected value, which should return some options. // + ///////////////////////////////////////////////////////////////////////////////// + response = Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=&useCase=form") + .field("values", """ + {"email":"tsamples@mmltholdings.com"} + """) + .asString(); + options = assertPossibleValueSuccessfulResponseAndGetOptionsArray(response); + assertNotNull(options); + assertEquals(1, options.length()); + assertEquals("Tyler Samples (4)", options.getJSONObject(0).getString("label")); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // finally an unrecognized useCase (or missing or empty), should behave the same as a form, and return 0 options if the filter-value is missing. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + assertPossibleValueSuccessfulResponseWithNoOptions(Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=&useCase=notAUseCase").asString()); + assertPossibleValueSuccessfulResponseWithNoOptions(Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=").asString()); + assertPossibleValueSuccessfulResponseWithNoOptions(Unirest.post(BASE_URL + "/data/pet/possibleValues/ownerPersonId?searchTerm=&useCase=").asString()); } From ed1e2519342df18b75ff98075793b2877f0e9bd6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 23 Aug 2024 10:01:20 -0500 Subject: [PATCH 2/9] CE-1646 Fix expected message on one test --- .../qqq/backend/core/actions/tables/QQueryFilterTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java index 0ed7b54f..2e64a870 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QQueryFilterTest.java @@ -222,7 +222,7 @@ class QQueryFilterTest extends BaseTest QQueryFilter filter = new QQueryFilter(new QFilterCriteria("id", EQUALS, expression)); assertThatThrownBy(() -> filter.interpretValues(inputValues, new ThrowExceptionUseCase())) .isInstanceOf(QUserFacingException.class) - .hasMessageContaining("Missing value for criteria on field: id"); + .hasMessageContaining("Missing value for variable: clientId"); } } From c90def42f51340977790e10c1f17892c99799ea6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 23 Aug 2024 11:39:10 -0500 Subject: [PATCH 3/9] Update for next development version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index cb05a087..f2384d93 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,7 @@ - 0.21.0 + 0.22.0-SNAPSHOT UTF-8 UTF-8 From 89cf23a65ab1f9cd07fcf7fe5a251f052eda75ff Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 23 Aug 2024 11:50:41 -0500 Subject: [PATCH 4/9] Updating to 0.22.0 --- qqq-dev-tools/CURRENT-SNAPSHOT-VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION b/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION index 88541566..21574090 100644 --- a/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION +++ b/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION @@ -1 +1 @@ -0.21.0 +0.22.0 From 89e0fc566d49712c34c9ac937d9cfc37d5e71df5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 23 Aug 2024 12:16:44 -0500 Subject: [PATCH 5/9] Try to fix flaky test --- .../C3P0PooledConnectionProviderTest.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/C3P0PooledConnectionProviderTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/C3P0PooledConnectionProviderTest.java index 417fd6cc..c588a06a 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/C3P0PooledConnectionProviderTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/jdbc/C3P0PooledConnectionProviderTest.java @@ -102,6 +102,14 @@ class C3P0PooledConnectionProviderTest extends BaseTest backend.setConnectionProvider(new QCodeReference(C3P0PooledConnectionProvider.class)); QContext.init(qInstance, new QSession()); + ///////////////////////////////////////////////////////////////////////////////// + // sometimes we're seeing this test fail w/ only 2 connections in the pool... // + // theory is, maybe, the pool doesn't quite have enough time to open them all? // + // so, try adding a little sleep here. // + ///////////////////////////////////////////////////////////////////////////////// + new QueryAction().execute(new QueryInput(TestUtils.TABLE_NAME_PERSON)); + SleepUtils.sleep(500, TimeUnit.MILLISECONDS); + for(int i = 0; i < 5; i++) { new QueryAction().execute(new QueryInput(TestUtils.TABLE_NAME_PERSON)); @@ -110,17 +118,18 @@ class C3P0PooledConnectionProviderTest extends BaseTest JSONObject debugValues = getDebugStateValues(true); assertThat(debugValues.getInt("numConnections")).isBetween(3, 6); // due to potential timing issues, sometimes pool will acquire another 3 conns, so 3 or 6 seems ok. - //////////////////////////////////////////////////////////////////// - // open up 4 transactions - confirm the pool opens some new conns // - //////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////// + // open up several transactions - confirm the pool opens some new conns // + ////////////////////////////////////////////////////////////////////////// + int noTransactions = 7; List transactions = new ArrayList<>(); - for(int i = 0; i < 5; i++) + for(int i = 0; i < noTransactions; i++) { transactions.add(QBackendTransaction.openFor(new InsertInput(TestUtils.TABLE_NAME_PERSON))); } debugValues = getDebugStateValues(true); - assertThat(debugValues.getInt("numConnections")).isGreaterThan(3); + assertThat(debugValues.getInt("numConnections")).isGreaterThanOrEqualTo(noTransactions); transactions.forEach(transaction -> transaction.close()); @@ -128,7 +137,7 @@ class C3P0PooledConnectionProviderTest extends BaseTest // might take a second for the pool to re-claim the closed connections // ///////////////////////////////////////////////////////////////////////// boolean foundMatch = false; - for(int i = 0; i < 5; i++) + for(int i = 0; i < noTransactions; i++) { debugValues = getDebugStateValues(true); if(debugValues.getInt("numConnections") == debugValues.getInt("numIdleConnections")) From 666f4a872dcf3393732b5737ae757e73ed45d203 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 23 Aug 2024 14:36:23 -0500 Subject: [PATCH 6/9] CE-1646 add use-cases to preserve the previous behavior for whether a report w/ missing input criteria values should fail or not --- .../reporting/GenerateReportAction.java | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java index eff97f4a..8acdf977 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java @@ -63,6 +63,8 @@ import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.QueryHint; import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaMissingInputValueBehavior; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.FilterUseCase; import com.kingsrook.qqq.backend.core.model.actions.tables.query.JoinsContext; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; @@ -614,7 +616,56 @@ public class GenerateReportAction extends AbstractQActionFunction Date: Fri, 23 Aug 2024 16:56:42 -0500 Subject: [PATCH 7/9] CE-1643 Update AbstractFilterExpression.evaluate to take in a QFieldMetaData - so that, in the temporal-based implementations, we can handle DATE_TIMEs differently from DATEs, where we were having RDBMS queries not return expected results, due to Instants being bound instead of LocalDates. --- .../expressions/AbstractFilterExpression.java | 5 +- .../expressions/FilterVariableExpression.java | 3 +- .../actions/tables/query/expressions/Now.java | 25 +++- .../query/expressions/NowWithOffset.java | 52 ++++++++- .../query/expressions/ThisOrLastPeriod.java | 87 +++++++++++++- .../memory/MemoryRecordStore.java | 25 +++- .../utils/BackendQueryFilterUtils.java | 68 ++++++++++- .../qqq/backend/core/utils/ValueUtils.java | 47 ++++++++ .../backend/core/utils/ValueUtilsTest.java | 26 +++++ .../actions/AbstractMongoDBAction.java | 2 +- qqq-backend-module-rdbms/pom.xml | 5 + .../rdbms/actions/AbstractRDBMSAction.java | 2 +- .../rdbms/actions/RDBMSQueryActionTest.java | 109 +++++++++++++++++- 13 files changed, 433 insertions(+), 23 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/AbstractFilterExpression.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/AbstractFilterExpression.java index fe9cc897..fe3f4ddd 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/AbstractFilterExpression.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/AbstractFilterExpression.java @@ -25,6 +25,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions; import java.io.Serializable; import java.util.Map; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; /******************************************************************************* @@ -35,7 +36,7 @@ public abstract class AbstractFilterExpression implement /******************************************************************************* ** *******************************************************************************/ - public abstract T evaluate() throws QException; + public abstract T evaluate(QFieldMetaData field) throws QException; @@ -47,7 +48,7 @@ public abstract class AbstractFilterExpression implement *******************************************************************************/ public T evaluateInputValues(Map inputValues) throws QException { - return evaluate(); + return evaluate(null); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/FilterVariableExpression.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/FilterVariableExpression.java index 5c3be8f7..71fc9d02 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/FilterVariableExpression.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/FilterVariableExpression.java @@ -26,6 +26,7 @@ import java.io.Serializable; import java.util.Map; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.utils.ValueUtils; @@ -45,7 +46,7 @@ public class FilterVariableExpression extends AbstractFilterExpression +public class Now extends AbstractFilterExpression { /******************************************************************************* ** *******************************************************************************/ @Override - public Instant evaluate() throws QException + public Serializable evaluate(QFieldMetaData field) throws QException { - return (Instant.now()); + QFieldType type = field == null ? QFieldType.DATE_TIME : field.getType(); + + if(type.equals(QFieldType.DATE_TIME)) + { + return (Instant.now()); + } + else if(type.equals(QFieldType.DATE)) + { + ZoneId zoneId = ValueUtils.getSessionOrInstanceZoneId(); + return (Instant.now().atZone(zoneId).toLocalDate()); + } + else + { + throw (new QException("Unsupported field type [" + type + "]")); + } } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffset.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffset.java index c941ea9e..694dee91 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffset.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffset.java @@ -22,19 +22,24 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions; +import java.io.Serializable; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeUnit; import com.kingsrook.qqq.backend.core.exceptions.QException; +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.utils.ValueUtils; /******************************************************************************* ** *******************************************************************************/ -public class NowWithOffset extends AbstractFilterExpression +public class NowWithOffset extends AbstractFilterExpression { private Operator operator; private int amount; @@ -123,7 +128,30 @@ public class NowWithOffset extends AbstractFilterExpression ** *******************************************************************************/ @Override - public Instant evaluate() throws QException + public Serializable evaluate(QFieldMetaData field) throws QException + { + QFieldType type = field == null ? QFieldType.DATE_TIME : field.getType(); + + if(type.equals(QFieldType.DATE_TIME)) + { + return (evaluateForDateTime()); + } + else if(type.equals(QFieldType.DATE)) + { + return (evaluateForDate()); + } + else + { + throw (new QException("Unsupported field type [" + type + "]")); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private Instant evaluateForDateTime() { ///////////////////////////////////////////////////////////////////////////// // Instant doesn't let us plus/minus WEEK, MONTH, or YEAR... // @@ -147,6 +175,26 @@ public class NowWithOffset extends AbstractFilterExpression + /*************************************************************************** + ** + ***************************************************************************/ + private LocalDate evaluateForDate() + { + ZoneId zoneId = ValueUtils.getSessionOrInstanceZoneId(); + LocalDate now = Instant.now().atZone(zoneId).toLocalDate(); + + if(operator.equals(Operator.PLUS)) + { + return (now.plus(amount, timeUnit)); + } + else + { + return (now.minus(amount, timeUnit)); + } + } + + + /******************************************************************************* ** Getter for operator ** diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/ThisOrLastPeriod.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/ThisOrLastPeriod.java index 0f0e10ce..9f154a01 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/ThisOrLastPeriod.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/ThisOrLastPeriod.java @@ -22,27 +22,32 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions; +import java.io.Serializable; import java.time.DayOfWeek; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.ChronoField; import java.time.temporal.ChronoUnit; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QRuntimeException; +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.utils.ValueUtils; /******************************************************************************* ** *******************************************************************************/ -public class ThisOrLastPeriod extends AbstractFilterExpression +public class ThisOrLastPeriod extends AbstractFilterExpression { private Operator operator; private ChronoUnit timeUnit; + /*************************************************************************** ** ***************************************************************************/ @@ -88,7 +93,7 @@ public class ThisOrLastPeriod extends AbstractFilterExpression ** Factory ** *******************************************************************************/ - public static ThisOrLastPeriod last(int amount, ChronoUnit timeUnit) + public static ThisOrLastPeriod last(ChronoUnit timeUnit) { return (new ThisOrLastPeriod(Operator.LAST, timeUnit)); } @@ -99,7 +104,31 @@ public class ThisOrLastPeriod extends AbstractFilterExpression ** *******************************************************************************/ @Override - public Instant evaluate() throws QException + public Serializable evaluate(QFieldMetaData field) throws QException + { + QFieldType type = field == null ? QFieldType.DATE_TIME : field.getType(); + + if(type.equals(QFieldType.DATE_TIME)) + { + return (evaluateForDateTime()); + } + else if(type.equals(QFieldType.DATE)) + { + // return (evaluateForDateTime()); + return (evaluateForDate()); + } + else + { + throw (new QException("Unsupported field type [" + type + "]")); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private Instant evaluateForDateTime() { ZoneId zoneId = ValueUtils.getSessionOrInstanceZoneId(); @@ -154,7 +183,57 @@ public class ThisOrLastPeriod extends AbstractFilterExpression return operator.equals(Operator.THIS) ? startOfThisYear : startOfLastYear; } - default -> throw (new QRuntimeException("Unsupported timeUnit: " + timeUnit)); + default -> throw (new QRuntimeException("Unsupported unit: " + timeUnit)); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public LocalDate evaluateForDate() + { + ZoneId zoneId = ValueUtils.getSessionOrInstanceZoneId(); + LocalDate today = Instant.now().atZone(zoneId).toLocalDate(); + + switch(timeUnit) + { + case DAYS -> + { + return operator.equals(Operator.THIS) ? today : today.minusDays(1); + } + case WEEKS -> + { + LocalDate startOfThisWeek = today; + while(startOfThisWeek.get(ChronoField.DAY_OF_WEEK) != DayOfWeek.SUNDAY.getValue()) + { + //////////////////////////////////////// + // go backwards until sunday is found // + //////////////////////////////////////// + startOfThisWeek = startOfThisWeek.minusDays(1); + } + return operator.equals(Operator.THIS) ? startOfThisWeek : startOfThisWeek.minusDays(7); + } + case MONTHS -> + { + Instant startOfThisMonth = ValueUtils.getStartOfMonthInZoneId(zoneId.getId()); + LocalDateTime startOfThisMonthLDT = LocalDateTime.ofInstant(startOfThisMonth, ZoneId.of(zoneId.getId())); + LocalDateTime startOfLastMonthLDT = startOfThisMonthLDT.minusMonths(1); + Instant startOfLastMonth = startOfLastMonthLDT.toInstant(ZoneId.of(zoneId.getId()).getRules().getOffset(Instant.now())); + + return (operator.equals(Operator.THIS) ? startOfThisMonth : startOfLastMonth).atZone(zoneId).toLocalDate(); + } + case YEARS -> + { + Instant startOfThisYear = ValueUtils.getStartOfYearInZoneId(zoneId.getId()); + LocalDateTime startOfThisYearLDT = LocalDateTime.ofInstant(startOfThisYear, zoneId); + LocalDateTime startOfLastYearLDT = startOfThisYearLDT.minusYears(1); + Instant startOfLastYear = startOfLastYearLDT.toInstant(zoneId.getRules().getOffset(Instant.now())); + + return (operator.equals(Operator.THIS) ? startOfThisYear : startOfLastYear).atZone(zoneId).toLocalDate(); + } + default -> throw (new QRuntimeException("Unsupported unit: " + timeUnit)); } } 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 a547aaba..84c5928f 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 @@ -58,6 +58,7 @@ 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.JoinsContext; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; @@ -170,6 +171,8 @@ public class MemoryRecordStore Collection tableData = getTableData(input.getTable()).values(); List records = new ArrayList<>(); + QQueryFilter filter = clonedOrNewFilter(input.getFilter()); + JoinsContext joinsContext = new JoinsContext(QContext.getQInstance(), input.getTableName(), input.getQueryJoins(), filter); if(CollectionUtils.nullSafeHasContents(input.getQueryJoins())) { tableData = buildJoinCrossProduct(input); @@ -185,7 +188,7 @@ public class MemoryRecordStore qRecord.setTableName(input.getTableName()); } - boolean recordMatches = BackendQueryFilterUtils.doesRecordMatch(input.getFilter(), qRecord); + boolean recordMatches = BackendQueryFilterUtils.doesRecordMatch(input.getFilter(), joinsContext, qRecord); if(recordMatches) { @@ -224,8 +227,7 @@ public class MemoryRecordStore *******************************************************************************/ private Collection buildJoinCrossProduct(QueryInput input) throws QException { - QInstance qInstance = QContext.getQInstance(); - JoinsContext joinsContext = new JoinsContext(qInstance, input.getTableName(), input.getQueryJoins(), input.getFilter()); + QInstance qInstance = QContext.getQInstance(); List crossProduct = new ArrayList<>(); QTableMetaData leftTable = input.getTable(); @@ -901,4 +903,21 @@ public class MemoryRecordStore return ValueUtils.getValueAsFieldType(fieldType, aggregateValue); } + + + + /******************************************************************************* + ** Either clone the input filter (so we can change it safely), or return a new blank filter. + *******************************************************************************/ + protected QQueryFilter clonedOrNewFilter(QQueryFilter filter) + { + if(filter == null) + { + return (new QQueryFilter()); + } + else + { + return (filter.clone()); + } + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java index 6862e01f..281ee9cc 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java @@ -34,14 +34,18 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; 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.query.JoinsContext; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression; import com.kingsrook.qqq.backend.core.model.data.QRecord; +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.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.ValueUtils; import org.apache.commons.lang.NotImplementedException; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -58,8 +62,22 @@ public class BackendQueryFilterUtils /******************************************************************************* ** Test if record matches filter. - *******************************************************************************/ + ******************************************************************************/ public static boolean doesRecordMatch(QQueryFilter filter, QRecord qRecord) + { + return doesRecordMatch(filter, null, qRecord); + } + + + + /******************************************************************************* + ** Test if record matches filter - where we are executing a QueryAction, and + ** we have a JoinsContext. Note, if you don't have one of those, you can call + ** the overload of this method that doesn't take one, and everything downstream + ** /should/ be tolerant of that being absent... You just might not have the + ** benefit of things like knowing field-meta-data associated with criteria... + *******************************************************************************/ + public static boolean doesRecordMatch(QQueryFilter filter, JoinsContext joinsContext, QRecord qRecord) { if(filter == null || !filter.hasAnyCriteria()) { @@ -97,7 +115,36 @@ public class BackendQueryFilterUtils } } - boolean criterionMatches = doesCriteriaMatch(criterion, fieldName, value); + /////////////////////////////////////////////////////////////////////////////////////////////// + // Test if this criteria(on) matches the record. // + // As criteria have become more sophisticated over time, we would like to be able to know // + // what field they are for. In general, we'll try to get that from the query's JoinsContext. // + // But, in some scenarios, that isn't available - so - be safe and defer to simpler methods // + // that might not have the full field, when necessary. // + /////////////////////////////////////////////////////////////////////////////////////////////// + Boolean criterionMatches = null; + if(joinsContext != null) + { + JoinsContext.FieldAndTableNameOrAlias fieldAndTableNameOrAlias = null; + try + { + fieldAndTableNameOrAlias = joinsContext.getFieldAndTableNameOrAlias(criterion.getFieldName()); + } + catch(Exception e) + { + LOG.debug("Exception getting field from joinsContext", e, logPair("fieldName", criterion.getFieldName())); + } + + if(fieldAndTableNameOrAlias != null) + { + criterionMatches = doesCriteriaMatch(criterion, fieldAndTableNameOrAlias.field(), value); + } + } + + if(criterionMatches == null) + { + criterionMatches = doesCriteriaMatch(criterion, criterion.getFieldName(), value); + } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // add this new value to the existing recordMatches value - and if we can short circuit the remaining checks, do so. // @@ -131,11 +178,24 @@ public class BackendQueryFilterUtils + /*************************************************************************** + ** + ***************************************************************************/ + public static boolean doesCriteriaMatch(QFilterCriteria criterion, String fieldName, Serializable value) + { + QFieldMetaData field = new QFieldMetaData(fieldName, ValueUtils.inferQFieldTypeFromValue(value, QFieldType.STRING)); + return doesCriteriaMatch(criterion, field, value); + } + + + /******************************************************************************* ** *******************************************************************************/ - public static boolean doesCriteriaMatch(QFilterCriteria criterion, String fieldName, Serializable value) + private static boolean doesCriteriaMatch(QFilterCriteria criterion, QFieldMetaData field, Serializable value) { + String fieldName = field == null ? "__unknownField" : field.getName(); + ListIterator valueListIterator = criterion.getValues().listIterator(); while(valueListIterator.hasNext()) { @@ -144,7 +204,7 @@ public class BackendQueryFilterUtils { try { - valueListIterator.set(expression.evaluate()); + valueListIterator.set(expression.evaluate(field)); } catch(QException qe) { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/ValueUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/ValueUtils.java index 78a26d2a..f2ee6802 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/ValueUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/ValueUtils.java @@ -41,6 +41,7 @@ import java.util.List; import java.util.TimeZone; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QValueException; +import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum; import com.kingsrook.qqq.backend.core.model.session.QSession; @@ -51,6 +52,8 @@ import com.kingsrook.qqq.backend.core.model.session.QSession; *******************************************************************************/ public class ValueUtils { + private static final QLogger LOG = QLogger.getLogger(ValueUtils.class); + private static final DateTimeFormatter dateTimeFormatter_yyyyMMddWithDashes = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final DateTimeFormatter dateTimeFormatter_MdyyyyWithSlashes = DateTimeFormatter.ofPattern("M/d/yyyy"); private static final DateTimeFormatter dateTimeFormatter_yyyyMMdd = DateTimeFormatter.ofPattern("yyyyMMdd"); @@ -931,4 +934,48 @@ public class ValueUtils return (ZoneId.of(QContext.getQInstance().getDefaultTimeZoneId())); } + + + /*************************************************************************** + ** + ***************************************************************************/ + public static QFieldType inferQFieldTypeFromValue(Serializable value, QFieldType defaultIfCannotInfer) + { + if(value instanceof String) + { + return QFieldType.STRING; + } + else if(value instanceof Integer) + { + return QFieldType.INTEGER; + } + else if(value instanceof Long) + { + return QFieldType.LONG; + } + else if(value instanceof BigDecimal) + { + return QFieldType.DECIMAL; + } + else if(value instanceof Boolean) + { + return QFieldType.BOOLEAN; + } + else if(value instanceof Instant) + { + return QFieldType.DATE_TIME; + } + else if(value instanceof LocalDate) + { + return QFieldType.DATE; + } + else if(value instanceof LocalTime) + { + return QFieldType.TIME; + } + + LOG.debug("Could not infer QFieldType from value [" + (value == null ? "null" : value.getClass().getSimpleName()) + "]"); + + return defaultIfCannotInfer; + } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/ValueUtilsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/ValueUtilsTest.java index a726eca3..d61a56c1 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/ValueUtilsTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/ValueUtilsTest.java @@ -32,12 +32,14 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.Month; import java.time.ZoneId; +import java.util.ArrayList; import java.util.Calendar; import java.util.GregorianCalendar; import com.kingsrook.qqq.backend.core.BaseTest; import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QValueException; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.session.QSession; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -333,4 +335,28 @@ class ValueUtilsTest extends BaseTest assertEquals(ZoneId.of("UTC-05:00"), ValueUtils.getSessionOrInstanceZoneId()); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInferQFieldTypeFromValue() + { + assertNull(ValueUtils.inferQFieldTypeFromValue(null, null)); + assertNull(ValueUtils.inferQFieldTypeFromValue(new ArrayList<>(), null)); + assertEquals(QFieldType.HTML, ValueUtils.inferQFieldTypeFromValue(new ArrayList<>(), QFieldType.HTML)); + + assertEquals(QFieldType.STRING, ValueUtils.inferQFieldTypeFromValue("value", null)); + assertEquals(QFieldType.INTEGER, ValueUtils.inferQFieldTypeFromValue(1, null)); + assertEquals(QFieldType.INTEGER, ValueUtils.inferQFieldTypeFromValue(Integer.valueOf("1"), null)); + assertEquals(QFieldType.LONG, ValueUtils.inferQFieldTypeFromValue(10_000_000_000L, null)); + assertEquals(QFieldType.DECIMAL, ValueUtils.inferQFieldTypeFromValue(BigDecimal.ZERO, null)); + assertEquals(QFieldType.BOOLEAN, ValueUtils.inferQFieldTypeFromValue(true, null)); + assertEquals(QFieldType.BOOLEAN, ValueUtils.inferQFieldTypeFromValue(Boolean.FALSE, null)); + assertEquals(QFieldType.DATE_TIME, ValueUtils.inferQFieldTypeFromValue(Instant.now(), null)); + assertEquals(QFieldType.DATE, ValueUtils.inferQFieldTypeFromValue(LocalDate.now(), null)); + assertEquals(QFieldType.TIME, ValueUtils.inferQFieldTypeFromValue(LocalTime.now(), null)); + } + } \ No newline at end of file diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java index 8fd22d08..33529ade 100644 --- a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java +++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java @@ -569,7 +569,7 @@ public class AbstractMongoDBAction { try { - valueListIterator.set(expression.evaluate()); + valueListIterator.set(expression.evaluate(field)); } catch(QException qe) { diff --git a/qqq-backend-module-rdbms/pom.xml b/qqq-backend-module-rdbms/pom.xml index 3e9be513..a3dad12e 100644 --- a/qqq-backend-module-rdbms/pom.xml +++ b/qqq-backend-module-rdbms/pom.xml @@ -81,6 +81,11 @@ junit-jupiter-engine test + + org.junit.jupiter + junit-jupiter-params + test + org.assertj assertj-core 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 501a45ba..dd8fdaa2 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 @@ -676,7 +676,7 @@ public abstract class AbstractRDBMSAction { try { - valueListIterator.set(expression.evaluate()); + valueListIterator.set(expression.evaluate(field)); } catch(QException qe) { diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java index 2b084bf0..7697e976 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java @@ -24,11 +24,14 @@ package com.kingsrook.qqq.backend.module.rdbms.actions; import java.io.Serializable; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Consumer; import java.util.function.Predicate; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; @@ -38,20 +41,26 @@ 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.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.Now; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod; 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.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeFunction; import com.kingsrook.qqq.backend.module.rdbms.TestUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -84,6 +93,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest void afterEach() { AbstractRDBMSAction.setLogSQL(false); + QContext.getQSession().removeValue(QSession.VALUE_KEY_USER_TIMEZONE); } @@ -612,6 +622,101 @@ public class RDBMSQueryActionTest extends RDBMSActionTest + /******************************************************************************* + ** Adding additional test conditions, specifically for DATE-precision + *******************************************************************************/ + @ParameterizedTest() + @ValueSource(strings = { "UTC", "US/Eastern", "UTC+12" }) + void testMoreFilterExpressions(String userTimezone) throws QException + { + QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE, userTimezone); + + LocalDate today = Instant.now().atZone(ZoneId.of(userTimezone)).toLocalDate(); + LocalDate yesterday = today.minusDays(1); + LocalDate tomorrow = today.plusDays(1); + + new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON).withRecords(List.of( + new QRecord().withValue("email", "-").withValue("firstName", "yesterday").withValue("lastName", "ExpressionTest").withValue("birthDate", yesterday), + new QRecord().withValue("email", "-").withValue("firstName", "today").withValue("lastName", "ExpressionTest").withValue("birthDate", today), + new QRecord().withValue("email", "-").withValue("firstName", "tomorrow").withValue("lastName", "ExpressionTest").withValue("birthDate", tomorrow)) + )); + + UnsafeFunction, List, QException> testFunction = (filterConsumer) -> + { + QQueryFilter filter = new QQueryFilter().withCriteria("lastName", QCriteriaOperator.EQUALS, "ExpressionTest"); + filter.withOrderBy(new QFilterOrderBy("birthDate")); + filterConsumer.accept(filter); + + return QueryAction.execute(TestUtils.TABLE_NAME_PERSON, filter); + }; + + assertOneRecordWithFirstName("today", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.EQUALS, new Now())))); + assertOneRecordWithFirstName("tomorrow", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.GREATER_THAN, new Now())))); + assertOneRecordWithFirstName("yesterday", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, new Now())))); + assertTwoRecordsWithFirstNames("yesterday", "today", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, new Now())))); + assertTwoRecordsWithFirstNames("today", "tomorrow", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, new Now())))); + + assertNoOfRecords(0, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, NowWithOffset.minus(1, ChronoUnit.DAYS))))); + assertNoOfRecords(3, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, NowWithOffset.plus(1, ChronoUnit.DAYS))))); + assertOneRecordWithFirstName("yesterday", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.EQUALS, NowWithOffset.minus(1, ChronoUnit.DAYS))))); + assertOneRecordWithFirstName("tomorrow", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.EQUALS, NowWithOffset.plus(1, ChronoUnit.DAYS))))); + assertNoOfRecords(3, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, NowWithOffset.plus(1, ChronoUnit.WEEKS))))); + assertNoOfRecords(3, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, NowWithOffset.plus(1, ChronoUnit.MONTHS))))); + assertNoOfRecords(3, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, NowWithOffset.plus(1, ChronoUnit.YEARS))))); + + assertThatThrownBy(() -> testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, NowWithOffset.plus(1, ChronoUnit.HOURS))))) + .hasRootCauseMessage("Unsupported unit: Hours"); + + assertOneRecordWithFirstName("today", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.EQUALS, ThisOrLastPeriod.this_(ChronoUnit.DAYS))))); + assertOneRecordWithFirstName("yesterday", testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.EQUALS, ThisOrLastPeriod.last(ChronoUnit.DAYS))))); + assertNoOfRecords(3, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.GREATER_THAN, ThisOrLastPeriod.last(ChronoUnit.WEEKS))))); + assertNoOfRecords(3, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.GREATER_THAN, ThisOrLastPeriod.last(ChronoUnit.MONTHS))))); + assertNoOfRecords(3, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.GREATER_THAN, ThisOrLastPeriod.last(ChronoUnit.YEARS))))); + assertNoOfRecords(0, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, ThisOrLastPeriod.last(ChronoUnit.WEEKS))))); + assertNoOfRecords(0, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, ThisOrLastPeriod.last(ChronoUnit.MONTHS))))); + assertNoOfRecords(0, testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, ThisOrLastPeriod.last(ChronoUnit.YEARS))))); + + assertThatThrownBy(() -> testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, ThisOrLastPeriod.this_(ChronoUnit.HOURS))))) + .hasRootCauseMessage("Unsupported unit: Hours"); + assertThatThrownBy(() -> testFunction.apply(filter -> filter.withCriteria(new QFilterCriteria("birthDate", QCriteriaOperator.LESS_THAN, ThisOrLastPeriod.last(ChronoUnit.MINUTES))))) + .hasRootCauseMessage("Unsupported unit: Minutes"); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private void assertNoOfRecords(Integer expectedSize, List actualRecords) + { + assertEquals(expectedSize, actualRecords.size()); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private void assertOneRecordWithFirstName(String expectedFirstName, List actualRecords) + { + assertEquals(1, actualRecords.size()); + assertEquals(expectedFirstName, actualRecords.get(0).getValueString("firstName")); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private void assertTwoRecordsWithFirstNames(String expectedFirstName0, String expectedFirstName1, List actualRecords) + { + assertEquals(2, actualRecords.size()); + assertEquals(expectedFirstName0, actualRecords.get(0).getValueString("firstName")); + assertEquals(expectedFirstName1, actualRecords.get(1).getValueString("firstName")); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -1017,8 +1122,8 @@ public class RDBMSQueryActionTest extends RDBMSActionTest @Test void testFieldNamesToInclude() throws QException { - QQueryFilter filter = new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, 1); - QueryInput queryInput = new QueryInput(TestUtils.TABLE_NAME_PERSON).withFilter(filter); + QQueryFilter filter = new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, 1); + QueryInput queryInput = new QueryInput(TestUtils.TABLE_NAME_PERSON).withFilter(filter); QRecord record = new QueryAction().execute(queryInput.withFieldNamesToInclude(null)).getRecords().get(0); assertTrue(record.getValues().containsKey("id")); From 050208cdda4f779d1c86cd656c06ed5e6215b2d2 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 23 Aug 2024 20:30:15 -0500 Subject: [PATCH 8/9] CE-1643 Updated sig; added some local-date tests; made instant tests less dumb i hope --- .../query/expressions/NowWithOffsetTest.java | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffsetTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffsetTest.java index 9f043d1f..4655255d 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffsetTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffsetTest.java @@ -22,11 +22,16 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions; +import java.time.Instant; +import java.time.LocalDate; import java.time.temporal.ChronoUnit; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import org.assertj.core.data.Offset; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; /******************************************************************************* @@ -46,23 +51,39 @@ class NowWithOffsetTest extends BaseTest { long now = System.currentTimeMillis(); - long oneWeekAgoMillis = NowWithOffset.minus(1, ChronoUnit.WEEKS).evaluate().toEpochMilli(); - assertThat(oneWeekAgoMillis).isCloseTo(now - (7 * DAY_IN_MILLIS), Offset.offset(10_000L)); + QFieldMetaData dateTimeField = new QFieldMetaData("myDateTime", QFieldType.DATE_TIME); + QFieldMetaData dateField = new QFieldMetaData("myDate", QFieldType.DATE); - long oneWeekFromNowMillis = NowWithOffset.plus(2, ChronoUnit.WEEKS).evaluate().toEpochMilli(); - assertThat(oneWeekFromNowMillis).isCloseTo(now + (14 * DAY_IN_MILLIS), Offset.offset(10_000L)); + { + Offset allowedDiff = Offset.offset(100L); + Offset allowedDiffPlusOneDay = Offset.offset(100L + DAY_IN_MILLIS); + Offset allowedDiffPlusTwoDays = Offset.offset(100L + 2 * DAY_IN_MILLIS); - long oneMonthAgoMillis = NowWithOffset.minus(1, ChronoUnit.MONTHS).evaluate().toEpochMilli(); - assertThat(oneMonthAgoMillis).isCloseTo(now - (30 * DAY_IN_MILLIS), Offset.offset(10_000L + 2 * DAY_IN_MILLIS)); + long oneWeekAgoMillis = ((Instant) NowWithOffset.minus(1, ChronoUnit.WEEKS).evaluate(dateTimeField)).toEpochMilli(); + assertThat(oneWeekAgoMillis).isCloseTo(now - (7 * DAY_IN_MILLIS), allowedDiff); - long oneMonthFromNowMillis = NowWithOffset.plus(2, ChronoUnit.MONTHS).evaluate().toEpochMilli(); - assertThat(oneMonthFromNowMillis).isCloseTo(now + (60 * DAY_IN_MILLIS), Offset.offset(10_000L + 3 * DAY_IN_MILLIS)); + long twoWeeksFromNowMillis = ((Instant) NowWithOffset.plus(2, ChronoUnit.WEEKS).evaluate(dateTimeField)).toEpochMilli(); + assertThat(twoWeeksFromNowMillis).isCloseTo(now + (14 * DAY_IN_MILLIS), allowedDiff); - long oneYearAgoMillis = NowWithOffset.minus(1, ChronoUnit.YEARS).evaluate().toEpochMilli(); - assertThat(oneYearAgoMillis).isCloseTo(now - (365 * DAY_IN_MILLIS), Offset.offset(10_000L + 2 * DAY_IN_MILLIS)); + long oneMonthAgoMillis = ((Instant) NowWithOffset.minus(1, ChronoUnit.MONTHS).evaluate(dateTimeField)).toEpochMilli(); + assertThat(oneMonthAgoMillis).isCloseTo(now - (30 * DAY_IN_MILLIS), allowedDiffPlusOneDay); - long oneYearFromNowMillis = NowWithOffset.plus(2, ChronoUnit.YEARS).evaluate().toEpochMilli(); - assertThat(oneYearFromNowMillis).isCloseTo(now + (730 * DAY_IN_MILLIS), Offset.offset(10_000L + 3 * DAY_IN_MILLIS)); + long twoMonthsFromNowMillis = ((Instant) NowWithOffset.plus(2, ChronoUnit.MONTHS).evaluate(dateTimeField)).toEpochMilli(); + assertThat(twoMonthsFromNowMillis).isCloseTo(now + (60 * DAY_IN_MILLIS), allowedDiffPlusTwoDays); + + long oneYearAgoMillis = ((Instant) NowWithOffset.minus(1, ChronoUnit.YEARS).evaluate(dateTimeField)).toEpochMilli(); + assertThat(oneYearAgoMillis).isCloseTo(now - (365 * DAY_IN_MILLIS), allowedDiffPlusOneDay); + + long twoYearsFromNowMillis = ((Instant) NowWithOffset.plus(2, ChronoUnit.YEARS).evaluate(dateTimeField)).toEpochMilli(); + assertThat(twoYearsFromNowMillis).isCloseTo(now + (730 * DAY_IN_MILLIS), allowedDiffPlusTwoDays); + } + + { + assertThat(NowWithOffset.minus(1, ChronoUnit.WEEKS).evaluate(dateField)).isInstanceOf(LocalDate.class); + + assertEquals(LocalDate.now().minusDays(1), NowWithOffset.minus(1, ChronoUnit.DAYS).evaluate(dateField)); + assertEquals(LocalDate.now().minusDays(7), NowWithOffset.minus(1, ChronoUnit.WEEKS).evaluate(dateField)); + } } } From afb6aa3b895143e3f941fc294d8f19a50422cf63 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 5 Sep 2024 07:56:16 -0500 Subject: [PATCH 9/9] Update versions for release --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f2384d93..c6cb4676 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,7 @@ - 0.22.0-SNAPSHOT + 0.22.0 UTF-8 UTF-8