From 425629de52385a01ad90a6f22bf8c76613e033de Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 17 May 2024 15:58:53 -0500 Subject: [PATCH 1/7] Adding missing test --- .../Aggregate2DTableWidgetRendererTest.java | 96 ++++++++ .../dashboard/widgets/TableDataAssert.java | 215 ++++++++++++++++++ .../dashboard/widgets/TableDataRowAssert.java | 192 ++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRendererTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataAssert.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataRowAssert.java diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRendererTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRendererTest.java new file mode 100644 index 00000000..d8c70032 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRendererTest.java @@ -0,0 +1,96 @@ +/* + * 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.actions.dashboard.widgets; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for Aggregate2DTableWidgetRenderer + *******************************************************************************/ +class Aggregate2DTableWidgetRendererTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecords(List.of( + new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 50), + new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 50), + new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 50), + new QRecord().withValue("lastName", "Simpson").withValue("homeStateId", 49), + new QRecord().withValue("lastName", "Flanders").withValue("homeStateId", 49), + new QRecord().withValue("lastName", "Flanders").withValue("homeStateId", 49), + new QRecord().withValue("lastName", "Burns").withValue("homeStateId", 50) + ))); + + RenderWidgetInput input = new RenderWidgetInput(); + input.setWidgetMetaData(new QWidgetMetaData() + .withDefaultValue("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY) + .withDefaultValue("valueField", "id") + .withDefaultValue("rowField", "lastName") + .withDefaultValue("columnField", "homeStateId") + .withDefaultValue("orderBys", "row") + ); + RenderWidgetOutput output = new Aggregate2DTableWidgetRenderer().render(input); + TableData tableData = (TableData) output.getWidgetData(); + System.out.println(tableData.getRows()); + + TableDataAssert.assertThat(tableData) + .hasRowWithColumnContaining("_row", "Simpson", row -> + row.hasColumnContaining("50", "3") + .hasColumnContaining("49", "1") + .hasColumnContaining("_total", "4")) + .hasRowWithColumnContaining("_row", "Flanders", row -> + row.hasColumnContaining("50", "0") + .hasColumnContaining("49", "2") + .hasColumnContaining("_total", "2")) + .hasRowWithColumnContaining("_row", "Burns", row -> + row.hasColumnContaining("50", "1") + .hasColumnContaining("49", "0") + .hasColumnContaining("_total", "1")) + .hasRowWithColumnContaining("_row", "Total", row -> + row.hasColumnContaining("50", "4") + .hasColumnContaining("49", "3") + .hasColumnContaining("_total", "7")); + + List rowLabels = tableData.getRows().stream().map(r -> r.get("_row").toString()).toList(); + assertEquals(List.of("Burns", "Flanders", "Simpson", "Total"), rowLabels); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataAssert.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataAssert.java new file mode 100644 index 00000000..5724f815 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataAssert.java @@ -0,0 +1,215 @@ +/* + * Copyright © 2022-2023. ColdTrack . All Rights Reserved. + */ + +package com.kingsrook.qqq.backend.core.actions.dashboard.widgets; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.Assertions; + + +/******************************************************************************* + ** AssertJ assert class for widget TableData + *******************************************************************************/ +public class TableDataAssert extends AbstractAssert +{ + + /******************************************************************************* + ** + *******************************************************************************/ + protected TableDataAssert(TableData actual, Class selfType) + { + super(actual, selfType); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static TableDataAssert assertThat(RenderWidgetOutput widgetOutput) + { + Assertions.assertThat(widgetOutput).isNotNull(); + QWidgetData widgetData = widgetOutput.getWidgetData(); + Assertions.assertThat(widgetData).isNotNull(); + Assertions.assertThat(widgetData).isInstanceOf(TableData.class); + return (new TableDataAssert((TableData) widgetData, TableDataAssert.class)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static TableDataAssert assertThat(TableData actual) + { + return (new TableDataAssert(actual, TableDataAssert.class)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasSize(int expectedSize) + { + Assertions.assertThat(actual.getRows()).hasSize(expectedSize); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasSizeAtLeast(int sizeAtLeast) + { + Assertions.assertThat(actual.getRows()).hasSizeGreaterThanOrEqualTo(sizeAtLeast); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert doesNotHaveRowWithColumnContaining(String columnName, String containingValue) + { + for(Map row : actual.getRows()) + { + if(row.containsKey(columnName)) + { + String value = String.valueOf(row.get(columnName)); + if(value != null && value.contains(containingValue)) + { + failWithMessage("Failed because a row was found with a value in the [" + columnName + "] column containing [" + containingValue + "]" + + (containingValue.equals(value) ? "" : " (full value: [" + value + "]).")); + } + } + } + + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasRowWithColumnContaining(String columnName, String containingValue) + { + hasRowWithColumnContaining(columnName, containingValue, (row) -> + { + }); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasRowWithColumnContaining(String columnName, String containingValue, Consumer rowAsserter) + { + return hasRowWithColumnPredicate(columnName, value -> value != null && value.contains(containingValue), "containing [" + containingValue + "]", rowAsserter); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasRowWithColumnMatching(String columnName, String matchingValue) + { + hasRowWithColumnMatching(columnName, matchingValue, (row) -> + { + }); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasRowWithColumnMatching(String columnName, String matchingValue, Consumer rowAsserter) + { + return hasRowWithColumnPredicate(columnName, value -> value != null && value.matches(matchingValue), "matching [" + matchingValue + "]", rowAsserter); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasRowWithColumnEqualTo(String columnName, String equalToValue) + { + hasRowWithColumnEqualTo(columnName, equalToValue, (row) -> + { + }); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataAssert hasRowWithColumnEqualTo(String columnName, String equalToValue, Consumer rowAsserter) + { + return hasRowWithColumnPredicate(columnName, value -> Objects.equals(value, equalToValue), "equalTo [" + equalToValue + "]", rowAsserter); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private TableDataAssert hasRowWithColumnPredicate(String columnName, Predicate predicate, String predicateDescription, Consumer rowAsserter) + { + List foundValuesInColumn = new ArrayList<>(); + for(Map row : actual.getRows()) + { + if(row.containsKey(columnName)) + { + String value = String.valueOf(row.get(columnName)); + foundValuesInColumn.add(value); + + if(predicate.test(value)) + { + TableDataRowAssert tableDataRowAssert = TableDataRowAssert.assertThat(row); + rowAsserter.accept(tableDataRowAssert); + + return (this); + } + } + } + + if(actual.getRows().isEmpty()) + { + failWithMessage("Failed because there are no rows in the table."); + } + else if(foundValuesInColumn.isEmpty()) + { + failWithMessage("Failed to find any rows with a column named: [" + columnName + "]"); + } + else + { + failWithMessage("Failed to find a row with column [" + columnName + "] " + predicateDescription + + ".\nFound values were:\n" + StringUtils.join("\n", foundValuesInColumn)); + } + return (null); + } + +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataRowAssert.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataRowAssert.java new file mode 100644 index 00000000..83ecf3ad --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/TableDataRowAssert.java @@ -0,0 +1,192 @@ +/* + * Copyright © 2022-2023. ColdTrack . All Rights Reserved. + */ + +package com.kingsrook.qqq.backend.core.actions.dashboard.widgets; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.Assertions; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** AssertJ assert class for a row of data from a widget TableData + *******************************************************************************/ +public class TableDataRowAssert extends AbstractAssert> +{ + + /******************************************************************************* + ** + *******************************************************************************/ + protected TableDataRowAssert(Map actual, Class selfType) + { + super(actual, selfType); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static TableDataRowAssert assertThat(Map actual) + { + return (new TableDataRowAssert(actual, TableDataRowAssert.class)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataRowAssert hasColumnContaining(String columnName, String containingValue) + { + String value = String.valueOf(actual.get(columnName)); + Assertions.assertThat(value) + .withFailMessage("Expected column [" + columnName + "] in row [" + actual + "] to contain [" + containingValue + "], but it didn't") + .contains(containingValue); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataRowAssert hasNoSubRows() + { + Object subRowsObject = actual.get("subRows"); + if(subRowsObject != null) + { + @SuppressWarnings("unchecked") + List> subRowsList = (List>) subRowsObject; + if(!subRowsList.isEmpty()) + { + fail("Row [" + actual + "] should not have had any subRows, but it did."); + } + } + + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataRowAssert hasSubRowWithColumnContaining(String columnName, String containingValue) + { + hasSubRowWithColumnContaining(columnName, containingValue, (row) -> + { + }); + + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private TableDataRowAssert hasSubRowWithColumnPredicate(String columnName, Function predicate, String predicateDescription, Consumer rowAsserter) + { + Object subRowsObject = actual.get("subRows"); + Assertions.assertThat(subRowsObject) + .withFailMessage("subRows should not be null").isNotNull() + .withFailMessage("subRows should be a List").isInstanceOf(List.class); + + @SuppressWarnings("unchecked") + List> subRowsList = (List>) subRowsObject; + + List foundValuesInColumn = new ArrayList<>(); + for(Map row : subRowsList) + { + if(row.containsKey(columnName)) + { + String value = String.valueOf(row.get(columnName)); + foundValuesInColumn.add(value); + + if(value != null && predicate.apply(value)) + { + TableDataRowAssert tableDataRowAssert = TableDataRowAssert.assertThat(row); + rowAsserter.accept(tableDataRowAssert); + + return (this); + } + } + } + + if(foundValuesInColumn.isEmpty()) + { + failWithMessage("Failed to find any rows with a column named: [" + columnName + "]"); + } + else + { + failWithMessage("Failed to find a row with column [" + columnName + "] " + predicateDescription + + ".\nFound values were:\n" + StringUtils.join("\n", foundValuesInColumn)); + } + return (null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataRowAssert hasSubRowWithColumnMatching(String columnName, String matchesValue, Consumer rowAsserter) + { + Function predicate = (value) -> ValueUtils.getValueAsString(value).matches(matchesValue); + return hasSubRowWithColumnPredicate(columnName, predicate, " matching [" + matchesValue + "]", rowAsserter); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataRowAssert hasSubRowWithColumnContaining(String columnName, String containingValue, Consumer rowAsserter) + { + Function predicate = (value) -> ValueUtils.getValueAsString(value).contains(containingValue); + return hasSubRowWithColumnPredicate(columnName, predicate, " containing [" + containingValue + "]", rowAsserter); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TableDataRowAssert doesNotHaveSubRowWithColumnContaining(String columnName, String containingValue) + { + Object subRowsObject = actual.get("subRows"); + if(subRowsObject != null) + { + Assertions.assertThat(subRowsObject).withFailMessage("subRows should be a List").isInstanceOf(List.class); + + @SuppressWarnings("unchecked") + List> subRowsList = (List>) subRowsObject; + + for(Map row : subRowsList) + { + if(row.containsKey(columnName)) + { + String value = String.valueOf(row.get(columnName)); + if(value != null && value.contains(containingValue)) + { + failWithMessage("Failed because a row was found with a value in the [" + columnName + "] column containing [" + containingValue + "]" + + (containingValue.equals(value) ? "" : " (full value: [" + value + "]).")); + } + } + } + } + + return (this); + } + +} From 759972b70c1259ec4a4b2a74d1686404f5dd9dd7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 17 May 2024 15:59:14 -0500 Subject: [PATCH 2/7] Fix chicken-egg session from repeating all-access key values --- .../Auth0AuthenticationModule.java | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java index 03b8b540..f4895702 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java @@ -150,10 +150,7 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface //////////////////////////////////////////////////////////////////////////////////////////////////////////// // this is how we allow the actions within this class to work without themselves having a logged-in user. // //////////////////////////////////////////////////////////////////////////////////////////////////////////// - private static QSession chickenAndEggSession = new QSession() - { - - }; + private static QSession chickenAndEggSession = null; @@ -163,14 +160,29 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface *******************************************************************************/ private QSession getChickenAndEggSession() { - for(String typeName : QContext.getQInstance().getSecurityKeyTypes().keySet()) + if(chickenAndEggSession == null) { - QSecurityKeyType keyType = QContext.getQInstance().getSecurityKeyType(typeName); - if(StringUtils.hasContent(keyType.getAllAccessKeyName())) + //////////////////////////////////////////////////////////////////////////////// + // if the static field is null, then let's make a new session; // + // prime it with all all-access keys; and then set it in the static field. // + // and, if 2 threads get in here at the same time, no real harm will be done, // + // other than creating the session twice, and whoever loses the race, that'll // + // be the one that stays in the field // + //////////////////////////////////////////////////////////////////////////////// + QSession newChickenAndEggSession = new QSession(); + + for(String typeName : QContext.getQInstance().getSecurityKeyTypes().keySet()) { - chickenAndEggSession = chickenAndEggSession.withSecurityKeyValue(keyType.getAllAccessKeyName(), true); + QSecurityKeyType keyType = QContext.getQInstance().getSecurityKeyType(typeName); + if(StringUtils.hasContent(keyType.getAllAccessKeyName())) + { + newChickenAndEggSession.withSecurityKeyValue(keyType.getAllAccessKeyName(), true); + } } + + chickenAndEggSession = newChickenAndEggSession; } + return (chickenAndEggSession); } From 8816bc89c3b62466e20ded545bbba67658daf5b9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 17 May 2024 15:59:38 -0500 Subject: [PATCH 3/7] Add cases for merging an IN and IS_NOT_BLANK --- .../core/utils/QQueryFilterDeduper.java | 23 +++++++++++++++++++ .../core/utils/QQueryFilterDeduperTest.java | 20 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduper.java index b6d6e0d0..3370b68e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduper.java @@ -34,6 +34,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; 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.IN; +import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IS_NOT_BLANK; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.NOT_EQUALS; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.NOT_IN; @@ -311,6 +312,28 @@ public class QQueryFilterDeduper log.add("Merge two not-equals as not-in"); continue; } + else if(IN.equals(other.getOperator()) && IS_NOT_BLANK.equals(criteria.getOperator())) + { + ////////////////////////////////////////////////////////////////////////// + // for an IN and IS_NOT_BLANK, remove the IS_NOT_BLANK - it's redundant // + ////////////////////////////////////////////////////////////////////////// + iterator.remove(); + didAnyGood = true; + log.add("Removing redundant is-not-blank"); + continue; + } + else if(IS_NOT_BLANK.equals(other.getOperator()) && IN.equals(criteria.getOperator())) + { + ////////////////////////////////////////////////////////////////////////// + // for an IN and IS_NOT_BLANK, remove the IS_NOT_BLANK - it's redundant // + ////////////////////////////////////////////////////////////////////////// + other.setOperator(IN); + other.setValues(new ArrayList<>(criteria.getValues())); + iterator.remove(); + didAnyGood = true; + log.add("Removing redundant is-not-blank"); + continue; + } else { log.add("Fail because unhandled operator pair"); diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduperTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduperTest.java index 2f596de6..276dcbfb 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduperTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/QQueryFilterDeduperTest.java @@ -29,6 +29,7 @@ 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 com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IN; +import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IS_NOT_BLANK; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.NOT_EQUALS; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.NOT_IN; import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter.BooleanOperator.OR; @@ -352,4 +353,23 @@ class QQueryFilterDeduperTest extends BaseTest assertEquals(contradiction, dedupeFilter(contradiction)); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInAndIsNotBlank() + { + assertEquals(new QQueryFilter().withCriteria(new QFilterCriteria("f", IN, 1, 2)), dedupeFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("f", IN, 1, 2)) + .withCriteria(new QFilterCriteria("f", IS_NOT_BLANK)) + )); + + assertEquals(new QQueryFilter().withCriteria(new QFilterCriteria("f", IN, 1, 2)), dedupeFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("f", IS_NOT_BLANK)) + .withCriteria(new QFilterCriteria("f", IN, 1, 2)) + )); + } + } \ No newline at end of file From e10a1e40da6f5d0dab7a9f91130913145d4e678f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 17 May 2024 16:16:26 -0500 Subject: [PATCH 4/7] Implement min/max records enforcement --- .../processes/RunBackendStepAction.java | 42 ++++++++- .../processes/RunBackendStepActionTest.java | 92 ++++++++++++++++--- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepAction.java index fc23904d..ba9afdbd 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepAction.java @@ -34,6 +34,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +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.metadata.code.QCodeReference; @@ -82,7 +83,7 @@ public class RunBackendStepAction ////////////////////////////////////////////////////////////////////////////////////// // ensure input data is set as needed - use callback object to get anything missing // ////////////////////////////////////////////////////////////////////////////////////// - ensureRecordsAreInRequest(runBackendStepInput, backendStepMetaData); + ensureRecordsAreInRequest(runBackendStepInput, backendStepMetaData, process); ensureInputFieldsAreInRequest(runBackendStepInput, backendStepMetaData); //////////////////////////////////////////////////////////////////// @@ -167,7 +168,7 @@ public class RunBackendStepAction ** check if this step uses a record list - and if so, if we need to get one ** via the callback *******************************************************************************/ - private void ensureRecordsAreInRequest(RunBackendStepInput runBackendStepInput, QBackendStepMetaData step) throws QException + private void ensureRecordsAreInRequest(RunBackendStepInput runBackendStepInput, QBackendStepMetaData step, QProcessMetaData process) throws QException { QFunctionInputMetaData inputMetaData = step.getInputMetaData(); if(inputMetaData != null && inputMetaData.getRecordListMetaData() != null) @@ -190,9 +191,44 @@ public class RunBackendStepAction queryInput.setFilter(callback.getQueryFilter()); + ////////////////////////////////////////////////////////////////////////////////////////// + // if process has a max-no of records, set a limit on the process of that number plus 1 // + // (the plus 1 being so we can see "oh, you selected more than that many; error!" // + ////////////////////////////////////////////////////////////////////////////////////////// + if(process.getMaxInputRecords() != null) + { + if(callback.getQueryFilter() == null) + { + queryInput.setFilter(new QQueryFilter()); + } + + queryInput.getFilter().setLimit(process.getMaxInputRecords() + 1); + } + QueryOutput queryOutput = new QueryAction().execute(queryInput); runBackendStepInput.setRecords(queryOutput.getRecords()); - // todo - handle 0 results found? + + //////////////////////////////////////////////////////////////////////////////// + // if process defines a max, and more than the max were found, throw an error // + //////////////////////////////////////////////////////////////////////////////// + if(process.getMaxInputRecords() != null) + { + if(queryOutput.getRecords().size() > process.getMaxInputRecords()) + { + throw (new QUserFacingException("Too many records were selected for this process. At most, only " + process.getMaxInputRecords() + " can be selected.")); + } + } + + ///////////////////////////////////////////////////////////////////////////////// + // if process defines a min, and fewer than the min were found, throw an error // + ///////////////////////////////////////////////////////////////////////////////// + if(process.getMinInputRecords() != null) + { + if(queryOutput.getRecords().size() < process.getMinInputRecords()) + { + throw (new QUserFacingException("Too few records were selected for this process. At least " + process.getMinInputRecords() + " must be selected.")); + } + } } } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepActionTest.java index 839172d8..0caad01f 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/processes/RunBackendStepActionTest.java @@ -27,13 +27,21 @@ import java.math.BigDecimal; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.utils.TestUtils; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -53,7 +61,7 @@ public class RunBackendStepActionTest extends BaseTest { TestCallback callback = new TestCallback(); RunBackendStepInput request = new RunBackendStepInput(); - request.setProcessName("greet"); + request.setProcessName(TestUtils.PROCESS_NAME_GREET_PEOPLE); request.setStepName("prepare"); request.setCallback(callback); RunBackendStepOutput result = new RunBackendStepAction().execute(request); @@ -67,6 +75,60 @@ public class RunBackendStepActionTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testMinMaxInputRecords() throws QException + { + //////////////////////////////////////////// + // put a min-input-records on the process // + //////////////////////////////////////////// + QContext.getQInstance().getProcess(TestUtils.PROCESS_NAME_GREET_PEOPLE).withMinInputRecords(5); + + ////////////////////////////////////////////////////////////////////////////////////// + // insert fewer than that min - then run w/ non-filtered filter, and assert we fail // + ////////////////////////////////////////////////////////////////////////////////////// + for(int i = 0; i < 3; i++) + { + new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecord(new QRecord().withValue("firstName", String.valueOf(i)))); + } + + Supplier inputSupplier = () -> + { + RunBackendStepInput input = new RunBackendStepInput(); + input.setProcessName(TestUtils.PROCESS_NAME_GREET_PEOPLE); + input.setStepName("prepare"); + input.setCallback(QProcessCallbackFactory.forFilter(new QQueryFilter())); + return (input); + }; + + assertThatThrownBy(() -> new RunBackendStepAction().execute(inputSupplier.get())) + .isInstanceOf(QUserFacingException.class) + .hasMessageContaining("Too few records"); + + //////////////////////////////////////////////////// + // insert a few more - and then it should succeed // + //////////////////////////////////////////////////// + for(int i = 3; i < 10; i++) + { + new InsertAction().execute(new InsertInput(TestUtils.TABLE_NAME_PERSON_MEMORY).withRecord(new QRecord().withValue("firstName", String.valueOf(i)))); + } + + new RunBackendStepAction().execute(inputSupplier.get()); + + //////////////////////////////////////////////////////////// + // now put a max on the process, and it should fail again // + //////////////////////////////////////////////////////////// + QContext.getQInstance().getProcess(TestUtils.PROCESS_NAME_GREET_PEOPLE).withMaxInputRecords(8); + + assertThatThrownBy(() -> new RunBackendStepAction().execute(inputSupplier.get())) + .isInstanceOf(QUserFacingException.class) + .hasMessageContaining("Too many records"); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -100,20 +162,20 @@ public class RunBackendStepActionTest extends BaseTest for(QFieldMetaData field : fields) { rs.put(field.getName(), switch(field.getType()) - { - case STRING -> "ABC"; - case INTEGER -> 42; - case LONG -> 42L; - case DECIMAL -> new BigDecimal("47"); - case BOOLEAN -> true; - case DATE, TIME, DATE_TIME -> null; - case TEXT -> """ - ABC - XYZ"""; - case HTML -> "Oh my"; - case PASSWORD -> "myPa**word"; - case BLOB -> new byte[] { 1, 2, 3, 4 }; - }); + { + case STRING -> "ABC"; + case INTEGER -> 42; + case LONG -> 42L; + case DECIMAL -> new BigDecimal("47"); + case BOOLEAN -> true; + case DATE, TIME, DATE_TIME -> null; + case TEXT -> """ + ABC + XYZ"""; + case HTML -> "Oh my"; + case PASSWORD -> "myPa**word"; + case BLOB -> new byte[] { 1, 2, 3, 4 }; + }); } return (rs); } From 5a56b5d9b45ed19675a4c907a8e7d9e430fbb4d3 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 17 May 2024 16:17:37 -0500 Subject: [PATCH 5/7] Treat made-up primary keys as nulls... also, don't start them at -1 (which, idk, is maybe somewhat likely in some world? but instead of half of integer-min-value...) --- .../ValidateRecordSecurityLockHelper.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java index c8de0e4f..93713fff 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java @@ -101,7 +101,7 @@ public class ValidateRecordSecurityLockHelper // actually check lock values // //////////////////////////////// Map errorRecords = new HashMap<>(); - evaluateRecordLocks(table, records, action, locksToCheck, errorRecords, new ArrayList<>()); + evaluateRecordLocks(table, records, action, locksToCheck, errorRecords, new ArrayList<>(), madeUpPrimaryKeys); ///////////////////////////////// // propagate errors to records // @@ -141,7 +141,7 @@ public class ValidateRecordSecurityLockHelper ** BUT - WRITE locks - in their case, we read the record no matter what, and in ** here we need to verify we have a key that allows us to WRITE the record. *******************************************************************************/ - private static void evaluateRecordLocks(QTableMetaData table, List records, Action action, RecordSecurityLock recordSecurityLock, Map errorRecords, List treePosition) throws QException + private static void evaluateRecordLocks(QTableMetaData table, List records, Action action, RecordSecurityLock recordSecurityLock, Map errorRecords, List treePosition, Map madeUpPrimaryKeys) throws QException { if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) { @@ -152,7 +152,7 @@ public class ValidateRecordSecurityLockHelper for(RecordSecurityLock childLock : CollectionUtils.nonNullList(multiRecordSecurityLock.getLocks())) { treePosition.add(i); - evaluateRecordLocks(table, records, action, childLock, errorRecords, treePosition); + evaluateRecordLocks(table, records, action, childLock, errorRecords, treePosition, madeUpPrimaryKeys); treePosition.remove(treePosition.size() - 1); i++; } @@ -192,7 +192,7 @@ public class ValidateRecordSecurityLockHelper } Serializable recordSecurityValue = record.getValue(field.getName()); - List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action); + List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action, madeUpPrimaryKeys); if(CollectionUtils.nullSafeHasContents(recordErrors)) { errorRecords.computeIfAbsent(record.getValue(primaryKeyField), (k) -> new RecordWithErrors(record)).addAll(recordErrors, treePosition); @@ -337,7 +337,7 @@ public class ValidateRecordSecurityLockHelper for(QRecord inputRecord : inputRecords) { - List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action); + List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action, madeUpPrimaryKeys); if(CollectionUtils.nullSafeHasContents(recordErrors)) { errorRecords.computeIfAbsent(inputRecord.getValue(primaryKeyField), (k) -> new RecordWithErrors(inputRecord)).addAll(recordErrors, treePosition); @@ -370,14 +370,14 @@ public class ValidateRecordSecurityLockHelper { String primaryKeyField = table.getPrimaryKeyField(); Map madeUpPrimaryKeys = new HashMap<>(); - Integer madeUpPrimaryKey = -1; + Integer madeUpPrimaryKey = Integer.MIN_VALUE / 2; for(QRecord record : records) { if(record.getValue(primaryKeyField) == null) { madeUpPrimaryKeys.put(madeUpPrimaryKey, record); record.setValue(primaryKeyField, madeUpPrimaryKey); - madeUpPrimaryKey--; + madeUpPrimaryKey++; } } return madeUpPrimaryKeys; @@ -445,9 +445,9 @@ public class ValidateRecordSecurityLockHelper /******************************************************************************* ** *******************************************************************************/ - public static List validateRecordSecurityValue(QTableMetaData table, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action) + public static List validateRecordSecurityValue(QTableMetaData table, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action, Map madeUpPrimaryKeys) { - if(recordSecurityValue == null) + if(recordSecurityValue == null || (madeUpPrimaryKeys != null && madeUpPrimaryKeys.containsKey(recordSecurityValue))) { ///////////////////////////////////////////////////////////////// // handle null values - error if the NullValueBehavior is DENY // From ede497ee852a95a292ab46ce30639baab709e001 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 17 May 2024 16:18:08 -0500 Subject: [PATCH 6/7] Add todos referencing lock-tree --- .../qqq/backend/core/actions/tables/UpdateAction.java | 6 +++++- .../module/mongodb/actions/AbstractMongoDBAction.java | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java index 95d4badc..2b64c1d9 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.actions.tables; import java.io.Serializable; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -335,6 +336,9 @@ public class UpdateAction QTableMetaData table = updateInput.getTable(); QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField()); + ///////////////////////////////////////////////////////////// + // todo - evolve to use lock tree (e.g., from multi-locks) // + ///////////////////////////////////////////////////////////// List onlyWriteLocks = RecordSecurityLockFilters.filterForOnlyWriteLocks(CollectionUtils.nonNullList(table.getRecordSecurityLocks())); for(List page : CollectionUtils.getPages(updateInput.getRecords(), 1000)) @@ -395,7 +399,7 @@ public class UpdateAction QFieldType fieldType = table.getField(lock.getFieldName()).getType(); Serializable lockValue = ValueUtils.getValueAsFieldType(fieldType, oldRecord.getValue(lock.getFieldName())); - List errors = ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE); + List errors = ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE, Collections.emptyMap()); if(CollectionUtils.nullSafeHasContents(errors)) { errors.forEach(e -> record.addError(e)); 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 d1a28479..11f61c8d 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 @@ -405,6 +405,9 @@ public class AbstractMongoDBAction QQueryFilter securityFilter = new QQueryFilter(); securityFilter.setBooleanOperator(QQueryFilter.BooleanOperator.AND); + //////////////////////////////////// + // todo - evolve to use lock tree // + //////////////////////////////////// for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(table.getRecordSecurityLocks()))) { addSubFilterForRecordSecurityLock(QContext.getQInstance(), QContext.getQSession(), table, securityFilter, recordSecurityLock, null, table.getName(), false); From 6f6f9af17dee4574a57014fd742f617e3b5c24d7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 17 May 2024 16:40:00 -0500 Subject: [PATCH 7/7] Updates for tests for min/max records --- .../qqq/backend/core/instances/QInstanceValidatorTest.java | 2 +- .../com/kingsrook/qqq/backend/core/utils/TestUtils.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java index 1c6947a3..77e97133 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java @@ -1304,7 +1304,7 @@ public class QInstanceValidatorTest extends BaseTest { TableAutomationAction action = getAction0(qInstance); action.setCodeReference(null); - action.setProcessName(TestUtils.PROCESS_NAME_GREET_PEOPLE); + action.setProcessName(TestUtils.PROCESS_NAME_BASEPULL); }, "different table"); } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java index fdd0f5ab..da654f3d 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java @@ -1132,21 +1132,21 @@ public class TestUtils { return new QProcessMetaData() .withName(PROCESS_NAME_GREET_PEOPLE) - .withTableName(TABLE_NAME_PERSON) + .withTableName(TABLE_NAME_PERSON_MEMORY) .addStep(new QBackendStepMetaData() .withName("prepare") .withCode(new QCodeReference() .withName(MockBackendStep.class.getName()) .withCodeType(QCodeType.JAVA)) .withInputData(new QFunctionInputMetaData() - .withRecordListMetaData(new QRecordListMetaData().withTableName(TABLE_NAME_PERSON)) + .withRecordListMetaData(new QRecordListMetaData().withTableName(TABLE_NAME_PERSON_MEMORY)) .withFieldList(List.of( new QFieldMetaData("greetingPrefix", QFieldType.STRING), new QFieldMetaData("greetingSuffix", QFieldType.STRING) ))) .withOutputMetaData(new QFunctionOutputMetaData() .withRecordListMetaData(new QRecordListMetaData() - .withTableName(TABLE_NAME_PERSON) + .withTableName(TABLE_NAME_PERSON_MEMORY) .withField(new QFieldMetaData("fullGreeting", QFieldType.STRING)) ) .withFieldList(List.of(new QFieldMetaData("outputMessage", QFieldType.STRING))))