From 9af1fed422c4ef17f00392aa764cd2af2e22fe4c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 6 Jul 2023 18:53:10 -0500 Subject: [PATCH 1/2] Initial backend work to support datetime query expressions from frontend --- .../actions/tables/query/QFilterCriteria.java | 3 + .../expressions/AbstractFilterExpression.java | 21 +++ .../query/expressions/NowWithOffset.java | 62 +++++- .../query/expressions/ThisOrLastPeriod.java | 178 ++++++++++++++++++ .../QFilterCriteriaDeserializer.java | 124 ++++++++++++ .../query/expressions/NowWithOffsetTest.java | 68 +++++++ .../QFilterCriteriaDeserializerTest.java | 93 +++++++++ 7 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/ThisOrLastPeriod.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffsetTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java index e096343e..f43f7eb9 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java @@ -26,8 +26,10 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.serialization.QFilterCriteriaDeserializer; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -36,6 +38,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; * A single criteria Component of a Query * *******************************************************************************/ +@JsonDeserialize(using = QFilterCriteriaDeserializer.class) public class QFilterCriteria implements Serializable, Cloneable { private static final QLogger LOG = QLogger.getLogger(QFilterCriteria.class); 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 cc75d062..723fa818 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 @@ -34,4 +34,25 @@ public abstract class AbstractFilterExpression ** *******************************************************************************/ public abstract T evaluate(); + + + + /******************************************************************************* + ** To help with serialization, define a "type" in all subclasses + *******************************************************************************/ + public String getType() + { + return (getClass().getSimpleName()); + } + + + + /******************************************************************************* + ** noop - but here so serialization won't be upset about there being a type + ** in a json object. + *******************************************************************************/ + public void setType(String 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 9400313b..bf36d971 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 @@ -23,6 +23,10 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions; import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeUnit; @@ -31,9 +35,9 @@ import java.util.concurrent.TimeUnit; *******************************************************************************/ public class NowWithOffset extends AbstractFilterExpression { - private final Operator operator; - private final int amount; - private final TimeUnit timeUnit; + private Operator operator; + private int amount; + private ChronoUnit timeUnit; @@ -46,7 +50,17 @@ public class NowWithOffset extends AbstractFilterExpression ** Constructor ** *******************************************************************************/ - private NowWithOffset(Operator operator, int amount, TimeUnit timeUnit) + public NowWithOffset() + { + } + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + private NowWithOffset(Operator operator, int amount, ChronoUnit timeUnit) { this.operator = operator; this.amount = amount; @@ -59,7 +73,19 @@ public class NowWithOffset extends AbstractFilterExpression ** Factory ** *******************************************************************************/ + @Deprecated public static NowWithOffset minus(int amount, TimeUnit timeUnit) + { + return (minus(amount, timeUnit.toChronoUnit())); + } + + + + /******************************************************************************* + ** Factory + ** + *******************************************************************************/ + public static NowWithOffset minus(int amount, ChronoUnit timeUnit) { return (new NowWithOffset(Operator.MINUS, amount, timeUnit)); } @@ -70,7 +96,19 @@ public class NowWithOffset extends AbstractFilterExpression ** Factory ** *******************************************************************************/ + @Deprecated public static NowWithOffset plus(int amount, TimeUnit timeUnit) + { + return (plus(amount, timeUnit.toChronoUnit())); + } + + + + /******************************************************************************* + ** Factory + ** + *******************************************************************************/ + public static NowWithOffset plus(int amount, ChronoUnit timeUnit) { return (new NowWithOffset(Operator.PLUS, amount, timeUnit)); } @@ -83,14 +121,24 @@ public class NowWithOffset extends AbstractFilterExpression @Override public Instant evaluate() { + ///////////////////////////////////////////////////////////////////////////// + // Instant doesn't let us plus/minus WEEK, MONTH, or YEAR... // + // but LocalDateTime does. So, make a LDT in UTC, do the plus/minus, then // + // convert back to Instant @ UTC // + ///////////////////////////////////////////////////////////////////////////// + LocalDateTime now = LocalDateTime.now(ZoneId.of("UTC")); + + LocalDateTime then; if(operator.equals(Operator.PLUS)) { - return (Instant.now().plus(amount, timeUnit.toChronoUnit())); + then = now.plus(amount, timeUnit); } else { - return (Instant.now().minus(amount, timeUnit.toChronoUnit())); + then = now.minus(amount, timeUnit); } + + return (then.toInstant(ZoneOffset.UTC)); } @@ -121,7 +169,7 @@ public class NowWithOffset extends AbstractFilterExpression ** Getter for timeUnit ** *******************************************************************************/ - public TimeUnit getTimeUnit() + public ChronoUnit getTimeUnit() { return timeUnit; } 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 new file mode 100644 index 00000000..28e53c37 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/ThisOrLastPeriod.java @@ -0,0 +1,178 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.expressions; + + +import java.time.DayOfWeek; +import java.time.Instant; +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.QRuntimeException; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class ThisOrLastPeriod extends AbstractFilterExpression +{ + private Operator operator; + private ChronoUnit timeUnit; + + + + public enum Operator + {THIS, LAST} + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public ThisOrLastPeriod() + { + } + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + private ThisOrLastPeriod(Operator operator, ChronoUnit timeUnit) + { + this.operator = operator; + this.timeUnit = timeUnit; + } + + + + /******************************************************************************* + ** Factory + ** + *******************************************************************************/ + public static ThisOrLastPeriod this_(ChronoUnit timeUnit) + { + return (new ThisOrLastPeriod(Operator.THIS, timeUnit)); + } + + + + /******************************************************************************* + ** Factory + ** + *******************************************************************************/ + public static ThisOrLastPeriod last(int amount, ChronoUnit timeUnit) + { + return (new ThisOrLastPeriod(Operator.LAST, timeUnit)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public Instant evaluate() + { + ZoneId zoneId = ValueUtils.getSessionOrInstanceZoneId(); + + switch(timeUnit) + { + case HOURS -> + { + if(operator.equals(Operator.THIS)) + { + return Instant.now().truncatedTo(ChronoUnit.HOURS); + } + else + { + return Instant.now().minus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS); + } + } + case DAYS -> + { + Instant startOfToday = ValueUtils.getStartOfTodayInZoneId(zoneId.getId()); + return operator.equals(Operator.THIS) ? startOfToday : startOfToday.minus(1, ChronoUnit.DAYS); + } + case WEEKS -> + { + Instant startOfToday = ValueUtils.getStartOfTodayInZoneId(zoneId.getId()); + LocalDateTime startOfThisWeekLDT = LocalDateTime.ofInstant(startOfToday, zoneId); + while(startOfThisWeekLDT.get(ChronoField.DAY_OF_WEEK) != DayOfWeek.SUNDAY.getValue()) + { + //////////////////////////////////////// + // go backwards until sunday is found // + //////////////////////////////////////// + startOfThisWeekLDT = startOfThisWeekLDT.minus(1, ChronoUnit.DAYS); + } + Instant startOfThisWeek = startOfThisWeekLDT.toInstant(zoneId.getRules().getOffset(startOfThisWeekLDT)); + + return operator.equals(Operator.THIS) ? startOfThisWeek : startOfThisWeek.minus(7, ChronoUnit.DAYS); + } + case MONTHS -> + { + Instant startOfThisMonth = ValueUtils.getStartOfMonthInZoneId(zoneId.getId()); + LocalDateTime startOfThisMonthLDT = LocalDateTime.ofInstant(startOfThisMonth, ZoneId.of(zoneId.getId())); + LocalDateTime startOfLastMonthLDT = startOfThisMonthLDT.minus(1, ChronoUnit.MONTHS); + Instant startOfLastMonth = startOfLastMonthLDT.toInstant(ZoneId.of(zoneId.getId()).getRules().getOffset(Instant.now())); + + return operator.equals(Operator.THIS) ? startOfThisMonth : startOfLastMonth; + } + case YEARS -> + { + Instant startOfThisYear = ValueUtils.getStartOfYearInZoneId(zoneId.getId()); + LocalDateTime startOfThisYearLDT = LocalDateTime.ofInstant(startOfThisYear, zoneId); + LocalDateTime startOfLastYearLDT = startOfThisYearLDT.minus(1, ChronoUnit.YEARS); + Instant startOfLastYear = startOfLastYearLDT.toInstant(zoneId.getRules().getOffset(Instant.now())); + + return operator.equals(Operator.THIS) ? startOfThisYear : startOfLastYear; + } + default -> throw (new QRuntimeException("Unsupported timeUnit: " + timeUnit)); + } + } + + + + /******************************************************************************* + ** Getter for operator + ** + *******************************************************************************/ + public Operator getOperator() + { + return operator; + } + + + + /******************************************************************************* + ** Getter for timeUnit + ** + *******************************************************************************/ + public ChronoUnit getTimeUnit() + { + return timeUnit; + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java new file mode 100644 index 00000000..ae4dfd64 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java @@ -0,0 +1,124 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.serialization; + + +import java.io.IOException; +import java.util.List; +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +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.expressions.AbstractFilterExpression; + + +/******************************************************************************* + ** Custom jackson deserializer, to deal w/ abstract expression field + *******************************************************************************/ +public class QFilterCriteriaDeserializer extends StdDeserializer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + public QFilterCriteriaDeserializer() + { + this(null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public QFilterCriteriaDeserializer(Class vc) + { + super(vc); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QFilterCriteria deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException + { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + ObjectMapper objectMapper = new ObjectMapper(); + + ///////////////////////////////// + // get values out of json node // + ///////////////////////////////// + List values = objectMapper.treeToValue(node.get("values"), List.class); + String fieldName = objectMapper.treeToValue(node.get("fieldName"), String.class); + QCriteriaOperator operator = objectMapper.treeToValue(node.get("operator"), QCriteriaOperator.class); + String otherFieldName = objectMapper.treeToValue(node.get("otherFieldName"), String.class); + + AbstractFilterExpression expression = null; + if(node.has("expression")) + { + JsonNode expressionNode = node.get("expression"); + String expressionType = objectMapper.treeToValue(expressionNode.get("type"), String.class); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // right now, we'll assume that all expression subclasses are in the same package as AbstractFilterExpression // + // so, we can just do a Class.forName on that name, and treeToValue like that. // + // if we ever had to, we could instead switch(expressionType), and do like so... // + // case "NowWithOffset" -> objectMapper.treeToValue(expressionNode, NowWithOffset.class); // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + try + { + String className = AbstractFilterExpression.class.getName().replace(AbstractFilterExpression.class.getSimpleName(), expressionType); + expression = (AbstractFilterExpression) objectMapper.treeToValue(expressionNode, Class.forName(className)); + } + catch(Exception e) + { + throw (new IOException("Error deserializing expression of type [" + expressionType + "] inside QFilterCriteria", e)); + } + } + + /////////////////////////////////// + // put fields into return object // + /////////////////////////////////// + QFilterCriteria criteria = new QFilterCriteria(); + criteria.setFieldName(fieldName); + criteria.setOperator(operator); + criteria.setValues(values); + criteria.setOtherFieldName(otherFieldName); + criteria.setExpression(expression); + + return (criteria); + + /* + int id = (Integer) (node.get("id")).numberValue(); + String itemName = node.get("itemName").asText(); + int userId = (Integer) (node.get("createdBy")).numberValue(); + + return null; + */ + } +} 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 new file mode 100644 index 00000000..8ef5737d --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/expressions/NowWithOffsetTest.java @@ -0,0 +1,68 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.expressions; + + +import java.time.temporal.ChronoUnit; +import com.kingsrook.qqq.backend.core.BaseTest; +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Unit test for NowWithOffset + *******************************************************************************/ +class NowWithOffsetTest extends BaseTest +{ + private static final long DAY_IN_MILLIS = 24 * 60 * 60 * 1000; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() + { + 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)); + + long oneWeekFromNowMillis = NowWithOffset.plus(2, ChronoUnit.WEEKS).evaluate().toEpochMilli(); + assertThat(oneWeekFromNowMillis).isCloseTo(now + (14 * DAY_IN_MILLIS), Offset.offset(10_000L)); + + 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 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 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 oneYearFromNowMillis = NowWithOffset.plus(2, ChronoUnit.YEARS).evaluate().toEpochMilli(); + assertThat(oneYearFromNowMillis).isCloseTo(now + (730 * DAY_IN_MILLIS), Offset.offset(10_000L + 3 * DAY_IN_MILLIS)); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java new file mode 100644 index 00000000..42f7c3da --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java @@ -0,0 +1,93 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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.serialization; + + +import java.io.IOException; +import java.time.temporal.ChronoUnit; +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression; +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.utils.JsonUtils; +import org.junit.jupiter.api.Test; +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.GREATER_THAN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for QFilterCriteriaDeserializer + *******************************************************************************/ +class QFilterCriteriaDeserializerTest extends BaseTest +{ + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDeserialize() throws IOException + { + { + QFilterCriteria criteria = JsonUtils.toObject(""" + {"fieldName": "id", "operator": "EQUALS", "values": [1]} + """, QFilterCriteria.class); + assertEquals("id", criteria.getFieldName()); + assertEquals(EQUALS, criteria.getOperator()); + assertEquals(List.of(1), criteria.getValues()); + } + + { + QFilterCriteria criteria = JsonUtils.toObject(""" + {"fieldName": "createDate", "operator": "GREATER_THAN", "expression": + {"type": "NowWithOffset", "operator": "PLUS", "amount": 5, "timeUnit": "MINUTES"} + } + """, QFilterCriteria.class); + assertEquals("createDate", criteria.getFieldName()); + assertEquals(GREATER_THAN, criteria.getOperator()); + assertNull(criteria.getValues()); + AbstractFilterExpression expression = criteria.getExpression(); + assertThat(expression).isInstanceOf(NowWithOffset.class); + NowWithOffset nowWithOffset = (NowWithOffset) expression; + assertEquals(5, nowWithOffset.getAmount()); + assertEquals(NowWithOffset.Operator.PLUS, nowWithOffset.getOperator()); + assertEquals(ChronoUnit.MINUTES, nowWithOffset.getTimeUnit()); + } + + { + QFilterCriteria criteria = JsonUtils.toObject(""" + {"fieldName": "orderDate", "operator": "EQUALS", "expression": {"type": "Now"} } + """, QFilterCriteria.class); + assertEquals("orderDate", criteria.getFieldName()); + assertEquals(EQUALS, criteria.getOperator()); + assertNull(criteria.getValues()); + AbstractFilterExpression expression = criteria.getExpression(); + assertThat(expression).isInstanceOf(Now.class); + } + + } +} \ No newline at end of file From 2422d09c314e9cd02830736bf86466d52eaf52c7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 13 Jul 2023 09:17:07 -0500 Subject: [PATCH 2/2] Stop doing criteria expressions as their own thing, and instead put them in the values list --- .../actions/tables/query/QFilterCriteria.java | 55 ++------------- .../expressions/AbstractFilterExpression.java | 2 +- .../QFilterCriteriaDeserializer.java | 67 ++++++++++--------- .../qqq/backend/core/utils/JsonUtils.java | 38 +++++++++++ .../QFilterCriteriaDeserializerTest.java | 39 ++++++++--- .../rdbms/actions/AbstractRDBMSAction.java | 19 ++++-- .../rdbms/actions/RDBMSQueryActionTest.java | 7 +- 7 files changed, 128 insertions(+), 99 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java index f43f7eb9..0072b6c9 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java @@ -28,7 +28,6 @@ import java.util.Arrays; import java.util.List; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.kingsrook.qqq.backend.core.logging.QLogger; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression; import com.kingsrook.qqq.backend.core.model.actions.tables.query.serialization.QFilterCriteriaDeserializer; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -47,8 +46,10 @@ public class QFilterCriteria implements Serializable, Cloneable private QCriteriaOperator operator; private List values; - private String otherFieldName; - private AbstractFilterExpression expression; + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // todo - probably implement this as a type of expression - though would require a little special handling i think when evaluating... // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + private String otherFieldName; @@ -101,23 +102,6 @@ public class QFilterCriteria implements Serializable, Cloneable - /******************************************************************************* - ** - *******************************************************************************/ - public QFilterCriteria(String fieldName, QCriteriaOperator operator, AbstractFilterExpression expression) - { - this.fieldName = fieldName; - this.operator = operator; - this.expression = expression; - - /////////////////////////////////////// - // this guy doesn't like to be null? // - /////////////////////////////////////// - this.values = new ArrayList<>(); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -335,35 +319,4 @@ public class QFilterCriteria implements Serializable, Cloneable return (rs.toString()); } - - - /******************************************************************************* - ** Getter for expression - *******************************************************************************/ - public AbstractFilterExpression getExpression() - { - return (this.expression); - } - - - - /******************************************************************************* - ** Setter for expression - *******************************************************************************/ - public void setExpression(AbstractFilterExpression expression) - { - this.expression = expression; - } - - - - /******************************************************************************* - ** Fluent setter for expression - *******************************************************************************/ - public QFilterCriteria withExpression(AbstractFilterExpression expression) - { - this.expression = expression; - return (this); - } - } 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 723fa818..cc58e9fa 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 @@ -28,7 +28,7 @@ import java.io.Serializable; /******************************************************************************* ** *******************************************************************************/ -public abstract class AbstractFilterExpression +public abstract class AbstractFilterExpression implements Serializable { /******************************************************************************* ** diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java index ae4dfd64..ca050b33 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializer.java @@ -23,7 +23,10 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query.serialization; import java.io.IOException; +import java.io.Serializable; import java.util.List; +import java.util.ListIterator; +import java.util.Map; import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; @@ -33,6 +36,9 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; 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.expressions.AbstractFilterExpression; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; /******************************************************************************* @@ -73,31 +79,39 @@ public class QFilterCriteriaDeserializer extends StdDeserializer values = objectMapper.treeToValue(node.get("values"), List.class); + String fieldName = objectMapper.treeToValue(node.get("fieldName"), String.class); + QCriteriaOperator operator = objectMapper.treeToValue(node.get("operator"), QCriteriaOperator.class); + String otherFieldName = objectMapper.treeToValue(node.get("otherFieldName"), String.class); - AbstractFilterExpression expression = null; - if(node.has("expression")) + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // look at all the values - if any of them are actually meant to be an Expression (instance of subclass of AbstractFilterExpression) // + // they'll have deserialized as a Map, with a "type" key. If that's the case, then re/de serialize them into the proper expression type // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + ListIterator valuesIterator = CollectionUtils.nonNullList(values).listIterator(); + while(valuesIterator.hasNext()) { - JsonNode expressionNode = node.get("expression"); - String expressionType = objectMapper.treeToValue(expressionNode.get("type"), String.class); + Object value = valuesIterator.next(); + if(value instanceof Map map && map.containsKey("type")) + { + String expressionType = ValueUtils.getValueAsString(map.get("type")); - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // right now, we'll assume that all expression subclasses are in the same package as AbstractFilterExpression // - // so, we can just do a Class.forName on that name, and treeToValue like that. // - // if we ever had to, we could instead switch(expressionType), and do like so... // - // case "NowWithOffset" -> objectMapper.treeToValue(expressionNode, NowWithOffset.class); // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - try - { - String className = AbstractFilterExpression.class.getName().replace(AbstractFilterExpression.class.getSimpleName(), expressionType); - expression = (AbstractFilterExpression) objectMapper.treeToValue(expressionNode, Class.forName(className)); - } - catch(Exception e) - { - throw (new IOException("Error deserializing expression of type [" + expressionType + "] inside QFilterCriteria", e)); + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // right now, we'll assume that all expression subclasses are in the same package as AbstractFilterExpression // + // so, we can just do a Class.forName on that name, and use JsonUtils.toObject requesting that class. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + try + { + String assumedExpressionJSON = JsonUtils.toJson(map); + + String className = AbstractFilterExpression.class.getName().replace(AbstractFilterExpression.class.getSimpleName(), expressionType); + Serializable replacementValue = (Serializable) JsonUtils.toObject(assumedExpressionJSON, Class.forName(className)); + valuesIterator.set(replacementValue); + } + catch(Exception e) + { + throw (new IOException("Error deserializing criteria value which appeared to be an expression of type [" + expressionType + "] inside QFilterCriteria", e)); + } } } @@ -109,16 +123,7 @@ public class QFilterCriteriaDeserializer extends StdDeserializer T toObject(String json, Class targetClass) throws IOException + { + return (toObject(json, targetClass, null)); + } + + + + /******************************************************************************* + ** De-serialize a json string into an object of the specified class - with + ** customizations on the Jackson ObjectMapper. + **. + ** + ** Internally using jackson - so jackson annotations apply! + ** + *******************************************************************************/ + public static T toObject(String json, Class targetClass, Consumer objectMapperCustomizer) throws IOException { ObjectMapper objectMapper = newObjectMapper(); + if(objectMapperCustomizer != null) + { + objectMapperCustomizer.accept(objectMapper); + } return objectMapper.reader().readValue(json, targetClass); } @@ -172,6 +191,25 @@ public class JsonUtils + /******************************************************************************* + ** De-serialize a json string into an object of the specified class - with + ** customizations on the Jackson ObjectMapper. + ** + ** Internally using jackson - so jackson annotations apply! + ** + *******************************************************************************/ + public static T toObject(String json, TypeReference typeReference, Consumer objectMapperCustomizer) throws IOException + { + ObjectMapper objectMapper = newObjectMapper(); + if(objectMapperCustomizer != null) + { + objectMapperCustomizer.accept(objectMapper); + } + return objectMapper.readValue(json, typeReference); + } + + + /******************************************************************************* ** De-serialize a json string into a JSONObject (string must start with "{") ** diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java index 42f7c3da..50f9bc8c 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/serialization/QFilterCriteriaDeserializerTest.java @@ -30,13 +30,15 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression; 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.utils.JsonUtils; 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.GREATER_THAN; 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.assertNull; /******************************************************************************* @@ -52,6 +54,11 @@ class QFilterCriteriaDeserializerTest extends BaseTest @Test void testDeserialize() throws IOException { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // just put a reference to this class here, so it's a tad easier to find this class via navigation in IDE... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + new QFilterCriteriaDeserializer(); + { QFilterCriteria criteria = JsonUtils.toObject(""" {"fieldName": "id", "operator": "EQUALS", "values": [1]} @@ -63,14 +70,13 @@ class QFilterCriteriaDeserializerTest extends BaseTest { QFilterCriteria criteria = JsonUtils.toObject(""" - {"fieldName": "createDate", "operator": "GREATER_THAN", "expression": - {"type": "NowWithOffset", "operator": "PLUS", "amount": 5, "timeUnit": "MINUTES"} + {"fieldName": "createDate", "operator": "GREATER_THAN", "values": + [{"type": "NowWithOffset", "operator": "PLUS", "amount": 5, "timeUnit": "MINUTES"}] } """, QFilterCriteria.class); assertEquals("createDate", criteria.getFieldName()); assertEquals(GREATER_THAN, criteria.getOperator()); - assertNull(criteria.getValues()); - AbstractFilterExpression expression = criteria.getExpression(); + AbstractFilterExpression expression = (AbstractFilterExpression) criteria.getValues().get(0); assertThat(expression).isInstanceOf(NowWithOffset.class); NowWithOffset nowWithOffset = (NowWithOffset) expression; assertEquals(5, nowWithOffset.getAmount()); @@ -80,14 +86,31 @@ class QFilterCriteriaDeserializerTest extends BaseTest { QFilterCriteria criteria = JsonUtils.toObject(""" - {"fieldName": "orderDate", "operator": "EQUALS", "expression": {"type": "Now"} } + {"fieldName": "orderDate", "operator": "EQUALS", "values": [{"type": "Now"}] } """, QFilterCriteria.class); assertEquals("orderDate", criteria.getFieldName()); assertEquals(EQUALS, criteria.getOperator()); - assertNull(criteria.getValues()); - AbstractFilterExpression expression = criteria.getExpression(); + AbstractFilterExpression expression = (AbstractFilterExpression) criteria.getValues().get(0); assertThat(expression).isInstanceOf(Now.class); } + { + QFilterCriteria criteria = JsonUtils.toObject(""" + {"fieldName": "orderDate", "operator": "BETWEEN", "values": [{"type": "Now"}, {"type": "ThisOrLastPeriod"}] } + """, QFilterCriteria.class); + assertEquals("orderDate", criteria.getFieldName()); + assertEquals(BETWEEN, criteria.getOperator()); + AbstractFilterExpression expression0 = (AbstractFilterExpression) criteria.getValues().get(0); + assertThat(expression0).isInstanceOf(Now.class); + AbstractFilterExpression expression1 = (AbstractFilterExpression) criteria.getValues().get(1); + assertThat(expression1).isInstanceOf(ThisOrLastPeriod.class); + } + + { + assertThatThrownBy(() -> JsonUtils.toObject(""" + {"fieldName": "orderDate", "operator": "BETWEEN", "values": [{"type": "NotAnExpressionType"}] } + """, QFilterCriteria.class)).hasMessageContaining("Error deserializing criteria value which appeared to be an expression"); + } + } } \ No newline at end of file diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java index 54d8ce3c..c0890aff 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 @@ -33,6 +33,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.ListIterator; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -57,6 +58,7 @@ 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.QueryJoin; +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.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat; @@ -713,14 +715,23 @@ public abstract class AbstractRDBMSAction implements QActionInterface ///////////////////////////////////////////////////////////////////// values = Collections.emptyList(); } - else if(expectedNoOfParams.equals(1) && criterion.getExpression() != null) - { - values = List.of(criterion.getExpression().evaluate()); - } else if(!expectedNoOfParams.equals(values.size())) { throw new IllegalArgumentException("Incorrect number of values given for criteria [" + field.getName() + "]"); } + + ////////////////////////////////////////////////////////////// + // replace any expression-type values with their evaluation // + ////////////////////////////////////////////////////////////// + ListIterator valueListIterator = values.listIterator(); + while(valueListIterator.hasNext()) + { + Serializable value = valueListIterator.next(); + if(value instanceof AbstractFilterExpression expression) + { + valueListIterator.set(expression.evaluate()); + } + } } clauses.add("(" + clause + ")"); 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 4e49d82a..b8558e12 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 @@ -29,7 +29,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; @@ -559,7 +558,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest QueryInput queryInput = initQueryRequest(); queryInput.setFilter(new QQueryFilter() .withCriteria(new QFilterCriteria().withFieldName("lastName").withOperator(QCriteriaOperator.EQUALS).withValues(List.of("ExpressionTest"))) - .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.LESS_THAN).withExpression(new Now()))); + .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.LESS_THAN).withValues(List.of(new Now())))); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); Assertions.assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); @@ -569,7 +568,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest QueryInput queryInput = initQueryRequest(); queryInput.setFilter(new QQueryFilter() .withCriteria(new QFilterCriteria().withFieldName("lastName").withOperator(QCriteriaOperator.EQUALS).withValues(List.of("ExpressionTest"))) - .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.LESS_THAN).withExpression(NowWithOffset.plus(2, TimeUnit.DAYS)))); + .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.LESS_THAN).withValues(List.of(NowWithOffset.plus(2, ChronoUnit.DAYS))))); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); Assertions.assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); @@ -579,7 +578,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest QueryInput queryInput = initQueryRequest(); queryInput.setFilter(new QQueryFilter() .withCriteria(new QFilterCriteria().withFieldName("lastName").withOperator(QCriteriaOperator.EQUALS).withValues(List.of("ExpressionTest"))) - .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.GREATER_THAN).withExpression(NowWithOffset.minus(5, TimeUnit.DAYS)))); + .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.GREATER_THAN).withValues(List.of(NowWithOffset.minus(5, ChronoUnit.DAYS))))); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); Assertions.assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row");