From 459629b4496062cad72d1788c9e21d5627130a34 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 16 Jan 2025 10:19:35 -0600 Subject: [PATCH 1/6] Promote FieldAndJoinTable up out of GenerateReportAction into top-level class, with factory method --- .../reporting/GenerateReportAction.java | 58 ++----------- .../metadata/fields/FieldAndJoinTable.java | 86 +++++++++++++++++++ ...ReportValuesDynamicFormWidgetRenderer.java | 6 +- .../SavedReportTableCustomizer.java | 4 +- 4 files changed, 96 insertions(+), 58 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/FieldAndJoinTable.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java index 8acdf977..b1e28903 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java @@ -71,6 +71,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAndJoinTable; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportDataSource; @@ -567,7 +568,7 @@ public class GenerateReportAction extends AbstractQActionFunction -1) - { - String joinTableName = fieldName.replaceAll("\\..*", ""); - String joinFieldName = fieldName.replaceAll(".*\\.", ""); - - QTableMetaData joinTable = QContext.getQInstance().getTable(joinTableName); - if(joinTable == null) - { - throw (new QException("Unrecognized join table name: " + joinTableName)); - } - - return new FieldAndJoinTable(joinTable.getField(joinFieldName), joinTable); - } - else - { - return new FieldAndJoinTable(mainTable.getField(fieldName), mainTable); - } - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -756,7 +731,7 @@ public class GenerateReportAction extends AbstractQActionFunction fields = new ArrayList<>(); for(String summaryFieldName : view.getSummaryFields()) { - FieldAndJoinTable fieldAndJoinTable = getFieldAndJoinTable(table, summaryFieldName); + FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, summaryFieldName); fields.add(new QFieldMetaData(summaryFieldName, fieldAndJoinTable.field().getType()).withLabel(fieldAndJoinTable.field().getLabel())); // todo do we need the type? if so need table as input here } for(QReportField column : view.getColumns()) @@ -1208,27 +1183,4 @@ public class GenerateReportAction extends AbstractQActionFunction. + */ + +package com.kingsrook.qqq.backend.core.model.metadata.fields; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Wrapper (record) that holds a QFieldMetaData and a QTableMetaData - + ** + ** With a factory method (`get()`) to go from the use-case of, a String that's + ** "joinTable.fieldName" or "fieldName" to the pair. + ** + ** Note that the "joinTable" member here - could be the "mainTable" passed in + ** to that `get()` method. + ** + *******************************************************************************/ +public record FieldAndJoinTable(QFieldMetaData field, QTableMetaData joinTable) +{ + + /*************************************************************************** + ** given a table, and a field-name string (which should either be the name + ** of a field on that table, or another tableName + "." + fieldName (from + ** that table) - get back the pair of table & field metaData that the + ** input string is talking about. + ***************************************************************************/ + public static FieldAndJoinTable get(QTableMetaData mainTable, String fieldName) throws QException + { + if(fieldName.indexOf('.') > -1) + { + String joinTableName = fieldName.replaceAll("\\..*", ""); + String joinFieldName = fieldName.replaceAll(".*\\.", ""); + + QTableMetaData joinTable = QContext.getQInstance().getTable(joinTableName); + if(joinTable == null) + { + throw (new QException("Unrecognized join table name: " + joinTableName)); + } + + return new FieldAndJoinTable(joinTable.getField(joinFieldName), joinTable); + } + else + { + return new FieldAndJoinTable(mainTable.getField(fieldName), mainTable); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public String getLabel(QTableMetaData mainTable) + { + if(mainTable.getName().equals(joinTable.getName())) + { + return (field.getLabel()); + } + else + { + return (joinTable.getLabel() + ": " + field.getLabel()); + } + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/ReportValuesDynamicFormWidgetRenderer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/ReportValuesDynamicFormWidgetRenderer.java index fc90fb1f..3a448eda 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/ReportValuesDynamicFormWidgetRenderer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/ReportValuesDynamicFormWidgetRenderer.java @@ -28,7 +28,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.reporting.GenerateReportAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.values.QPossibleValueTranslator; import com.kingsrook.qqq.backend.core.context.QContext; @@ -43,6 +42,7 @@ 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.DynamicFormWidgetData; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAndJoinTable; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.processes.implementations.savedreports.SavedReportToReportMetaDataAdapter; @@ -127,8 +127,8 @@ public class ReportValuesDynamicFormWidgetRenderer extends AbstractWidgetRendere { if(criteriaValue instanceof FilterVariableExpression filterVariableExpression) { - GenerateReportAction.FieldAndJoinTable fieldAndJoinTable = GenerateReportAction.getFieldAndJoinTable(table, criteria.getFieldName()); - QFieldMetaData fieldMetaData = fieldAndJoinTable.field().clone(); + FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, criteria.getFieldName()); + QFieldMetaData fieldMetaData = fieldAndJoinTable.field().clone(); ///////////////////////////////// // make name & label for field // diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportTableCustomizer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportTableCustomizer.java index 6fe6e9bf..65e11a10 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportTableCustomizer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportTableCustomizer.java @@ -28,7 +28,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; -import com.kingsrook.qqq.backend.core.actions.reporting.GenerateReportAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.reporting.pivottable.PivotTableDefinition; @@ -39,6 +38,7 @@ 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.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAndJoinTable; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage; @@ -311,7 +311,7 @@ public class SavedReportTableCustomizer implements TableCustomizerInterface { try { - GenerateReportAction.FieldAndJoinTable fieldAndJoinTable = GenerateReportAction.getFieldAndJoinTable(table, fieldName); + FieldAndJoinTable fieldAndJoinTable = FieldAndJoinTable.get(table, fieldName); return (fieldAndJoinTable.getLabel(table)); } catch(Exception e) From 84d41858b2ebd2a3c9c5fc4787099df858f071f9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 16 Jan 2025 10:22:02 -0600 Subject: [PATCH 2/6] Add method addJoinedRecordValues --- .../qqq/backend/core/model/data/QRecord.java | 20 ++++++++++++++++++- .../backend/core/model/data/QRecordTest.java | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecord.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecord.java index dffc5947..cc3b0f6c 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecord.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecord.java @@ -154,7 +154,7 @@ public class QRecord implements Serializable return (null); } - Map clone = new LinkedHashMap<>(); + Map clone = new LinkedHashMap<>(map.size()); for(Map.Entry entry : map.entrySet()) { Serializable value = entry.getValue(); @@ -246,6 +246,24 @@ public class QRecord implements Serializable } + /*************************************************************************** + ** copy all values from 'joinedRecord' into this record's values map, + ** prefixing field names with joinTableNam + "." + ***************************************************************************/ + public void addJoinedRecordValues(String joinTableName, QRecord joinedRecord) + { + if(joinedRecord == null) + { + return; + } + + for(Map.Entry entry : joinedRecord.getValues().entrySet()) + { + setValue(joinTableName + "." + entry.getKey(), entry.getValue()); + } + } + + /******************************************************************************* ** diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordTest.java index 0bd1ddf3..797a93a3 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordTest.java @@ -291,4 +291,24 @@ class QRecordTest extends BaseTest assertFalse(jsonObject.has("errorsAsString")); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testAddJoinedRecordValues() + { + QRecord order = new QRecord().withValue("id", 1).withValue("shipTo", "St. Louis"); + order.addJoinedRecordValues("orderInstructions", null); + assertEquals(2, order.getValues().size()); + + QRecord orderInstructions = new QRecord().withValue("id", 100).withValue("instructions", "Be Careful"); + order.addJoinedRecordValues("orderInstructions", orderInstructions); + + assertEquals(4, order.getValues().size()); + assertEquals(100, order.getValue("orderInstructions.id")); + assertEquals("Be Careful", order.getValue("orderInstructions.instructions")); + } + } \ No newline at end of file From 0fffed9d31e813e0a52061a376ab502f06c56fe7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 16 Jan 2025 10:22:09 -0600 Subject: [PATCH 3/6] Initial checkin --- .../model/data/QRecordWithJoinedRecords.java | 201 ++++++++++++++++++ .../data/QRecordWithJoinedRecordsTest.java | 76 +++++++ 2 files changed, 277 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecords.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecordsTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecords.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecords.java new file mode 100644 index 00000000..aa773687 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecords.java @@ -0,0 +1,201 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.data; + + +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BiFunction; + + +/******************************************************************************* + ** Extension on QRecord, intended to be used where you've got records from + ** multiple tables, and you want to combine them into a single "wide" joined + ** record - but to do so without copying or modifying any of the individual + ** records. + ** + ** e.g., given: + ** - Order (id, orderNo, orderDate) (main table) + ** - LineItem (id, sku, quantity) + ** - Extrinsic (id, key, value) + ** + ** If set up in here as: + ** - new QRecordWithJoinedRecords(order) + ** .withJoinedRecordValues(lineItem) + ** .withJoinedRecordValues(extrinsic) + ** + ** Then we'd have the appearance of values in the object like: + ** - id, orderNo, orderDate, lineItem.id, lineItem.sku, lineItem.quantity, extrinsic.id, extrinsic.key, extrinsic.value + ** + ** Which, by the by, is how a query that returns joined records looks, and, is + ** what BackendQueryFilterUtils can use to do filter. + ** + ** This is done without copying or mutating any of the records (which, if you just use + ** QRecord.withJoinedRecordValues, then those values are copied into the main record) + ** - because this object is just storing references to the input records. + ** + ** Note that this implies that, values changed in this record (e.g, calls to setValue) + ** WILL impact the underlying records! + *******************************************************************************/ +public class QRecordWithJoinedRecords extends QRecord +{ + private QRecord mainRecord; + private Map components = new LinkedHashMap<>(); + + + /*************************************************************************** + ** + ***************************************************************************/ + public QRecordWithJoinedRecords(QRecord mainRecord) + { + this.mainRecord = mainRecord; + } + + + + /************************************************************************* + ** + ***************************************************************************/ + @Override + public void addJoinedRecordValues(String joinTableName, QRecord joinedRecord) + { + components.put(joinTableName, joinedRecord); + } + + + + /************************************************************************* + ** + ***************************************************************************/ + public QRecordWithJoinedRecords withJoinedRecordValues(QRecord record, String joinTableName) + { + addJoinedRecordValues(joinTableName, record); + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public Serializable getValue(String fieldName) + { + return performFunctionOnRecordBasedOnFieldName(fieldName, ((record, f) -> record.getValue(f))); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public void setValue(String fieldName, Object value) + { + performFunctionOnRecordBasedOnFieldName(fieldName, ((record, f) -> + { + record.setValue(f, value); + return (null); + })); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public void setValue(String fieldName, Serializable value) + { + performFunctionOnRecordBasedOnFieldName(fieldName, ((record, f) -> + { + record.setValue(f, value); + return (null); + })); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void removeValue(String fieldName) + { + performFunctionOnRecordBasedOnFieldName(fieldName, ((record, f) -> + { + record.removeValue(f); + return (null); + })); + } + + + + /*************************************************************************** + ** avoid having this same block in all the functions that call it... + ** given a fieldName, which may be a joinTable.fieldName, apply the function + ** to the right entity. + ***************************************************************************/ + private Serializable performFunctionOnRecordBasedOnFieldName(String fieldName, BiFunction functionToPerform) + { + if(fieldName.contains(".")) + { + String[] parts = fieldName.split("\\."); + QRecord component = components.get(parts[0]); + if(component != null) + { + return functionToPerform.apply(component, parts[1]); + } + else + { + return null; + } + } + else + { + return functionToPerform.apply(mainRecord, fieldName); + } + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public Map getValues() + { + Map rs = new LinkedHashMap<>(mainRecord.getValues()); + for(Map.Entry componentEntry : components.entrySet()) + { + String joinTableName = componentEntry.getKey(); + QRecord componentRecord = componentEntry.getValue(); + for(Map.Entry entry : componentRecord.getValues().entrySet()) + { + rs.put(joinTableName + "." + entry.getKey(), entry.getValue()); + } + } + return (rs); + } + +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecordsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecordsTest.java new file mode 100644 index 00000000..2eb372d0 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordWithJoinedRecordsTest.java @@ -0,0 +1,76 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.data; + + +import java.time.LocalDate; +import java.time.Month; +import com.kingsrook.qqq.backend.core.BaseTest; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for QRecordWithJoinedRecords + *******************************************************************************/ +class QRecordWithJoinedRecordsTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() + { + QRecord order = new QRecord().withValue("id", 1).withValue("orderNo", "101").withValue("orderDate", LocalDate.of(2025, Month.JANUARY, 1)); + QRecord lineItem = new QRecord().withValue("id", 2).withValue("sku", "ABC").withValue("quantity", 47); + QRecord extrinsic = new QRecord().withValue("id", 3).withValue("key", "MyKey").withValue("value", "MyValue"); + + QRecordWithJoinedRecords joinedRecords = new QRecordWithJoinedRecords(order); + joinedRecords.addJoinedRecordValues("lineItem", lineItem); + joinedRecords.addJoinedRecordValues("extrinsic", extrinsic); + + assertEquals(1, joinedRecords.getValue("id")); + assertEquals("101", joinedRecords.getValue("orderNo")); + assertEquals(LocalDate.of(2025, Month.JANUARY, 1), joinedRecords.getValue("orderDate")); + assertEquals(2, joinedRecords.getValue("lineItem.id")); + assertEquals("ABC", joinedRecords.getValue("lineItem.sku")); + assertEquals(47, joinedRecords.getValue("lineItem.quantity")); + assertEquals(3, joinedRecords.getValue("extrinsic.id")); + assertEquals("MyKey", joinedRecords.getValue("extrinsic.key")); + assertEquals("MyValue", joinedRecords.getValue("extrinsic.value")); + + assertEquals(9, joinedRecords.getValues().size()); + assertEquals(1, joinedRecords.getValues().get("id")); + assertEquals(2, joinedRecords.getValues().get("lineItem.id")); + assertEquals(3, joinedRecords.getValues().get("extrinsic.id")); + + joinedRecords.setValue("lineItem.color", "RED"); + assertEquals("RED", joinedRecords.getValue("lineItem.color")); + assertEquals("RED", lineItem.getValue("color")); + + joinedRecords.setValue("shipToCity", "St. Louis"); + assertEquals("St. Louis", joinedRecords.getValue("shipToCity")); + assertEquals("St. Louis", order.getValue("shipToCity")); + } + +} \ No newline at end of file From 8c7e523e434a84b96769befadc1266b8f3b1ddb8 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 16 Jan 2025 10:23:22 -0600 Subject: [PATCH 4/6] Add concept of criteriaOptions - ways an application & backend can add modified behavior to a criteria --- .../actions/tables/query/CriteriaOption.java | 31 +++++++ .../tables/query/CriteriaOptionInterface.java | 30 +++++++ .../actions/tables/query/QFilterCriteria.java | 87 ++++++++++++++++++- .../actions/tables/query/QQueryFilter.java | 16 ++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOption.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOptionInterface.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOption.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOption.java new file mode 100644 index 00000000..e43cece8 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOption.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.actions.tables.query; + + +/******************************************************************************* + ** + *******************************************************************************/ +public enum CriteriaOption implements CriteriaOptionInterface +{ + CASE_INSENSITIVE; +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOptionInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOptionInterface.java new file mode 100644 index 00000000..44ffeb38 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/CriteriaOptionInterface.java @@ -0,0 +1,30 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.model.actions.tables.query; + + +/******************************************************************************* + ** + *******************************************************************************/ +public interface CriteriaOptionInterface +{ +} 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 118aacbf..dd5e7c8f 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.Collection; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; 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.serialization.QFilterCriteriaDeserializer; @@ -44,7 +46,7 @@ public class QFilterCriteria implements Serializable, Cloneable { private static final QLogger LOG = QLogger.getLogger(QFilterCriteria.class); - private String fieldName; + private String fieldName; private QCriteriaOperator operator; private List values; @@ -53,6 +55,8 @@ public class QFilterCriteria implements Serializable, Cloneable //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// private String otherFieldName; + private Set options = null; + /******************************************************************************* @@ -69,6 +73,13 @@ public class QFilterCriteria implements Serializable, Cloneable clone.values = new ArrayList<>(); clone.values.addAll(values); } + + if(options != null) + { + clone.options = new HashSet<>(); + clone.options.addAll(options); + } + return clone; } catch(CloneNotSupportedException e) @@ -385,4 +396,78 @@ public class QFilterCriteria implements Serializable, Cloneable return Objects.hash(fieldName, operator, values, otherFieldName); } + + + /******************************************************************************* + ** Getter for options + *******************************************************************************/ + public Set getOptions() + { + return (this.options); + } + + + + /******************************************************************************* + ** Setter for options + *******************************************************************************/ + public void setOptions(Set options) + { + this.options = options; + } + + + + /******************************************************************************* + ** Fluent setter for options + *******************************************************************************/ + public QFilterCriteria withOptions(Set options) + { + this.options = options; + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public QFilterCriteria withOption(CriteriaOptionInterface option) + { + if(options == null) + { + options = new HashSet<>(); + } + options.add(option); + return (this); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + public QFilterCriteria withoutOption(CriteriaOptionInterface option) + { + if(options != null) + { + options.remove(option); + } + return (this); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + public boolean hasOption(CriteriaOptionInterface option) + { + if(options == null) + { + return (false); + } + + return (options.contains(option)); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java index 0d18d56d..471bdcea 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java @@ -853,4 +853,20 @@ public class QQueryFilter implements Serializable, Cloneable } + /*************************************************************************** + ** + ***************************************************************************/ + public void applyCriteriaOptionToAllCriteria(CriteriaOptionInterface criteriaOption) + { + for(QFilterCriteria criteria : CollectionUtils.nonNullList(this.criteria)) + { + criteria.withOption(criteriaOption); + } + + for(QQueryFilter subFilter : CollectionUtils.nonNullList(subFilters)) + { + subFilter.applyCriteriaOptionToAllCriteria(criteriaOption); + } + } + } From d6288eee4aa53367b9676e1f9872baed1bc03bde Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 16 Jan 2025 10:24:01 -0600 Subject: [PATCH 5/6] Add support for CriteriaOption.CASE_INSENSITIVE --- .../utils/BackendQueryFilterUtils.java | 56 +++++++++++++++ .../utils/BackendQueryFilterUtilsTest.java | 69 +++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java index 281ee9cc..e59038e0 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java @@ -34,6 +34,7 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaOption; import com.kingsrook.qqq.backend.core.model.actions.tables.query.JoinsContext; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; @@ -268,6 +269,11 @@ public class BackendQueryFilterUtils String regex = sqlLikeToRegex(criterionValue); + if(criterion.hasOption(CriteriaOption.CASE_INSENSITIVE)) + { + return (stringValue.toLowerCase().matches(regex.toLowerCase())); + } + return (stringValue.matches(regex)); } @@ -427,6 +433,23 @@ public class BackendQueryFilterUtils } } + if(criterion.hasOption(CriteriaOption.CASE_INSENSITIVE)) + { + if(CollectionUtils.nullSafeHasContents(criterion.getValues())) + { + if(criterion.getValues().get(0) instanceof String) + { + for(Serializable criterionValue : criterion.getValues()) + { + if(criterionValue instanceof String criterionValueString && value instanceof String valueString && criterionValueString.equalsIgnoreCase(valueString)) + { + return (true); + } + } + } + } + } + if(value == null || !criterion.getValues().contains(value)) { return (false); @@ -456,6 +479,14 @@ public class BackendQueryFilterUtils value = String.valueOf(value); } + if(criterion.hasOption(CriteriaOption.CASE_INSENSITIVE)) + { + if(value instanceof String valueString && criteriaValue instanceof String criteriaValueString && valueString.equalsIgnoreCase(criteriaValueString)) + { + return (true); + } + } + if(!value.equals(criteriaValue)) { return (false); @@ -473,6 +504,14 @@ public class BackendQueryFilterUtils String stringValue = getStringFieldValue(value, fieldName, criterion); String criterionValue = getFirstStringCriterionValue(criterion); + if(criterion.hasOption(CriteriaOption.CASE_INSENSITIVE)) + { + if(stringValue.toLowerCase().contains(criterionValue.toLowerCase())) + { + return (true); + } + } + if(!stringValue.contains(criterionValue)) { return (false); @@ -491,6 +530,14 @@ public class BackendQueryFilterUtils String stringValue = getStringFieldValue(value, fieldName, criterion); String criterionValue = getFirstStringCriterionValue(criterion); + if(criterion.hasOption(CriteriaOption.CASE_INSENSITIVE)) + { + if(stringValue.toLowerCase().startsWith(criterionValue.toLowerCase())) + { + return (true); + } + } + if(!stringValue.startsWith(criterionValue)) { return (false); @@ -509,6 +556,14 @@ public class BackendQueryFilterUtils String stringValue = getStringFieldValue(value, fieldName, criterion); String criterionValue = getFirstStringCriterionValue(criterion); + if(criterion.hasOption(CriteriaOption.CASE_INSENSITIVE)) + { + if(stringValue.toLowerCase().endsWith(criterionValue.toLowerCase())) + { + return (true); + } + } + if(!stringValue.endsWith(criterionValue)) { return (false); @@ -665,4 +720,5 @@ public class BackendQueryFilterUtils regex.append("$"); return regex.toString(); } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java index afc3ad1f..1f922a2e 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java @@ -22,8 +22,10 @@ package com.kingsrook.qqq.backend.core.modules.backend.implementations.utils; +import java.io.Serializable; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaOption; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; @@ -305,6 +307,73 @@ class BackendQueryFilterUtilsTest + /*************************************************************************** + ** + ***************************************************************************/ + private QFilterCriteria newCaseInsensitiveCriteria(String fieldName, QCriteriaOperator operator, Serializable... values) + { + return new QFilterCriteria(fieldName, operator, values).withOption(CriteriaOption.CASE_INSENSITIVE); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + private QFilterCriteria newCaseInsensitiveCriteria(String fieldName, QCriteriaOperator operator, List values) + { + return new QFilterCriteria(fieldName, operator, values).withOption(CriteriaOption.CASE_INSENSITIVE); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDoesCriterionMatchCaseInsensitive() + { + //////////////// + // like & not // + //////////////// + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.LIKE, "Test"), "f", "test")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.LIKE, "test"), "f", "Test")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.LIKE, "T%"), "f", "test")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.LIKE, "t%"), "f", "Test")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.LIKE, "T_st"), "f", "test")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.LIKE, "t_st"), "f", "Test")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_LIKE, "Test"), "f", "Tst")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_LIKE, "Test"), "f", "tst")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_LIKE, "T%"), "f", "Rest")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_LIKE, "T_st"), "f", "Toast")); + + ////////////// + // IN & NOT // + ////////////// + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.IN, "A"), "f", "a")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.IN, "a"), "f", "A")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.IN, "A", "B"), "f", "a")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.IN, "A", "b"), "f", "B")); + assertFalse(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.IN, List.of()), "f", "A")); + assertFalse(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.IN, ListBuilder.of(null)), "f", "A")); + + assertFalse(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_IN, "A"), "f", "A")); + assertFalse(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_IN, "A", "B"), "f", "a")); + assertFalse(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_IN, "A", "b"), "f", "B")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_IN, List.of()), "f", "A")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_IN, ListBuilder.of(null)), "f", "A")); + + /////////////////////////// + // NOT_EQUALS_OR_IS_NULL // + /////////////////////////// + assertFalse(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, "A"), "f", "A")); + assertFalse(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, "A"), "f", "a")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, "A"), "f", "B")); + assertTrue(BackendQueryFilterUtils.doesCriteriaMatch(newCaseInsensitiveCriteria("f", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, "A"), "f", null)); + } + + + /******************************************************************************* ** *******************************************************************************/ From 109e390bc37a4759303fe96b807251cc4560541f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 16 Jan 2025 10:24:25 -0600 Subject: [PATCH 6/6] Add explicit log (Rather than NPE) for unknown table name --- .../core/actions/tables/helpers/QueryStatManager.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/QueryStatManager.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/QueryStatManager.java index 0d75e60a..a2bdbe52 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/QueryStatManager.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/QueryStatManager.java @@ -533,11 +533,17 @@ public class QueryStatManager //////////////////////// if(getOutput.getRecord() == null) { + QTableMetaData tableMetaData = getInstance().qInstance.getTable(tableName); + if(tableMetaData == null) + { + LOG.info("No such table", logPair("tableName", tableName)); + return (null); + } + /////////////////////////////////////////////////////// // insert the record (into the table, not the cache) // /////////////////////////////////////////////////////// - QTableMetaData tableMetaData = getInstance().qInstance.getTable(tableName); - InsertInput insertInput = new InsertInput(); + InsertInput insertInput = new InsertInput(); insertInput.setTableName(QQQTable.TABLE_NAME); insertInput.setRecords(List.of(new QRecord().withValue("name", tableName).withValue("label", tableMetaData.getLabel()))); InsertOutput insertOutput = new InsertAction().execute(insertInput);