joinsInFilter = BackendQueryFilterUtils.identifyJoinsInFilter(joinedRecordHelper.getTableName(), filter);
+ joinsInFilter = sortQueryJoinsFromMainTableOutward(joinedRecordHelper.getTableName(), joinsInFilter);
for(QueryJoin join : joinsInFilter)
{
- crossProduct = expandCrossProductViaJoin(crossProduct, join, context);
+ crossProduct = expandCrossProductViaJoin(crossProduct, join, joinedRecordHelper);
}
return crossProduct;
@@ -157,16 +174,96 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
+ /*******************************************************************************
+ * Sort the query joins so that they fan outward from the main table to ones
+ * farther away.
+ *
+ * This is so, if we've got a -> b -> c -- we'll process 'b' before 'c',
+ * as we need the records in 'b' to find the records in 'c'.
+ *
+ * Note that this method was copied from {@code AbstractRDBMSAction}
+ *******************************************************************************/
+ private static List sortQueryJoinsFromMainTableOutward(String mainTableName, List queryJoins)
+ {
+ List rs = new ArrayList<>();
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // make a copy of the input list that we can feel safe removing elements from //
+ ////////////////////////////////////////////////////////////////////////////////
+ List inputListCopy = new ArrayList<>(queryJoins);
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////
+ // keep track of the tables (or aliases) that we've seen - that's what we'll "grow" outward from //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////
+ Set seenTablesOrAliases = new HashSet<>();
+ seenTablesOrAliases.add(mainTableName);
+
+ ////////////////////////////////////////////////////////////////////////////////////
+ // loop as long as there are more tables in the inputList, and the keepGoing flag //
+ // is set (e.g., indicating that we added something in the last iteration) //
+ ////////////////////////////////////////////////////////////////////////////////////
+ boolean keepGoing = true;
+ while(!inputListCopy.isEmpty() && keepGoing)
+ {
+ keepGoing = false;
+
+ Iterator iterator = inputListCopy.iterator();
+ while(iterator.hasNext())
+ {
+ QueryJoin nextQueryJoin = iterator.next();
+
+ //////////////////////////////////////////////////////////////////////////
+ // get the baseTableOrAlias from this join - and if it isn't set in the //
+ // QueryJoin, then get it from the left-side of the join's metaData //
+ //////////////////////////////////////////////////////////////////////////
+ String baseTableOrAlias = nextQueryJoin.getBaseTableOrAlias();
+ if(baseTableOrAlias == null && nextQueryJoin.getJoinMetaData() != null)
+ {
+ baseTableOrAlias = nextQueryJoin.getJoinMetaData().getLeftTable();
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // if we have a baseTableOrAlias (would we ever not?), and we've seen it before - OR - we've seen this query join's joinTableOrAlias, //
+ // then we can add this pair of namesOrAliases to our seen-set, remove this queryJoin from the inputListCopy (iterator), and keep going //
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ if((StringUtils.hasContent(baseTableOrAlias) && seenTablesOrAliases.contains(baseTableOrAlias)) || seenTablesOrAliases.contains(nextQueryJoin.getJoinTableOrItsAlias()))
+ {
+ rs.add(nextQueryJoin);
+ if(StringUtils.hasContent(baseTableOrAlias))
+ {
+ seenTablesOrAliases.add(baseTableOrAlias);
+ }
+
+ seenTablesOrAliases.add(nextQueryJoin.getJoinTableOrItsAlias());
+ iterator.remove();
+ keepGoing = true;
+ }
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // in case any are left, add them all here - does this ever happen? //
+ // the only time a conditional breakpoint here fires in the RDBMS test suite, is in query designed to throw. //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ rs.addAll(inputListCopy);
+
+ return (rs);
+ }
+
+
+
+
+
/***************************************************************************
*
***************************************************************************/
- private static List expandCrossProductViaJoin(List orderWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContext context) throws QException
+ private static List expandCrossProductViaJoin(List recordWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContextJoinedRecordHelper joinedRecordHelper) throws QException
{
- String joinTableName = queryJoin.getJoinTable();
- ArrayList recordsToBeInserted = context.recordsToInsert.get().computeIfAbsent(joinTableName, k -> new ArrayList<>());
- List recordsAlreadyInBackend = context.getJoinRecords(queryJoin);
- HashSet idsToDelete = context.primaryKeysToDelete.get().computeIfAbsent(joinTableName, k -> new HashSet<>());
- String primaryKeyField = QContext.getQInstance().getTable(joinTableName).getPrimaryKeyField();
+ String joinTableName = queryJoin.getJoinTable();
+ List recordsToBeInserted = joinedRecordHelper.getRecordsToInsert(joinTableName);
+ List recordsAlreadyInBackend = joinedRecordHelper.getJoinRecords(queryJoin);
+ Set idsToDelete = joinedRecordHelper.getPrimaryKeysToDelete(joinTableName);
+ String primaryKeyField = QContext.getQInstance().getTable(joinTableName).getPrimaryKeyField();
//////////////////////////////////////////////////////////////////////////////////////////////////////
// add records that already existed to the cross product, filtering out ones that are to be deleted //
@@ -185,8 +282,8 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
////////////////////////////////////
CollectionUtils.addAllIfNotNull(recordsToCross, recordsToBeInserted);
- orderWithJoinedRecords = makeCrossProduct(orderWithJoinedRecords, joinTableName, recordsToCross);
- return orderWithJoinedRecords;
+ recordWithJoinedRecords = makeCrossProduct(recordWithJoinedRecords, joinTableName, recordsToCross);
+ return recordWithJoinedRecords;
}
@@ -194,18 +291,21 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
/***************************************************************************
*
***************************************************************************/
- private static List makeCrossProduct(List orderWithJoinedRecords, String joinTableName, List joinRecodsToCross)
+ private static List makeCrossProduct(List recordsWithJoinedRecords, String joinTableName, List joinRecordsToCross)
{
- if(!joinRecodsToCross.isEmpty())
+ if(recordsWithJoinedRecords.isEmpty() || joinRecordsToCross.isEmpty())
{
- List newCrossProduct = new ArrayList<>();
- for(QRecordWithJoinedRecords orderWithJoinedRecord : orderWithJoinedRecords)
- {
- newCrossProduct.addAll(orderWithJoinedRecord.buildCrossProduct(joinTableName, joinRecodsToCross));
- }
- orderWithJoinedRecords = newCrossProduct;
+ return new ArrayList<>();
}
- return orderWithJoinedRecords;
+
+ List newCrossProduct = new ArrayList<>();
+ for(QRecordWithJoinedRecords recordWithJoinedRecord : recordsWithJoinedRecords)
+ {
+ newCrossProduct.addAll(recordWithJoinedRecord.buildCrossProduct(joinTableName, joinRecordsToCross));
+ }
+ recordsWithJoinedRecords = newCrossProduct;
+
+ return recordsWithJoinedRecords;
}
diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java
index 0d23764..a2dd14a 100644
--- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java
+++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java
@@ -25,24 +25,16 @@ package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
-import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
-import com.google.gson.reflect.TypeToken;
+import com.kingsrook.qbits.workflows.execution.LazyInitObjectInWorkflowContext;
import com.kingsrook.qbits.workflows.execution.ObjectInWorkflowContext;
import com.kingsrook.qbits.workflows.execution.WorkflowExecutionContext;
-import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
-import com.kingsrook.qqq.backend.core.exceptions.QException;
-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;
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.data.QRecordEntity;
-import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
-import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
@@ -57,17 +49,43 @@ public class RecordWorkflowContext extends WorkflowExecutionContext
public final ObjectInWorkflowContext>> recordsToInsert = new ObjectInWorkflowContext<>(this, "recordsToInsert", new HashMap<>());
public final ObjectInWorkflowContext>> primaryKeysToDelete = new ObjectInWorkflowContext<>(this, "primaryKeysToDelete", new HashMap<>());
- ///////////////////////////////////////////////////////////////////////////////////////////
- // records already stored in the backend that are joined with the main record. //
- // should only be accessed via the getRelatedRecords method, which lazy inits (by query) //
- ///////////////////////////////////////////////////////////////////////////////////////////
- private final ObjectInWorkflowContext>> joinRecords = new ObjectInWorkflowContext<>(this, "joinRecords", new HashMap<>());
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // records already stored in the backend that are joined with the main record - they used to be stored in a map kept //
+ // right here, but they've migrated into the RecordWorkflowContextJoinedRecordHelper. //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ public final ObjectInWorkflowContext recordWorkflowContextJoinedRecordHelper = new LazyInitObjectInWorkflowContext<>(this, "recordWorkflowContextJoinedRecordHelper", () -> newRecordWorkflowContextJoinedRecordHelper(this));
- private record JoinKey(String joinName, ArrayList joinValues)
+ /***************************************************************************
+ * Construct a new RecordWorkflowContextJoinedRecordHelper, which uses this
+ * context class's members to get the list of records to insert and the
+ * set of ids to delete
+ ***************************************************************************/
+ private RecordWorkflowContextJoinedRecordHelper newRecordWorkflowContextJoinedRecordHelper(RecordWorkflowContext recordWorkflowContext)
{
+ return new RecordWorkflowContextJoinedRecordHelper(getWorkflow().getTableName(), record.get())
+ {
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ @Override
+ public List getRecordsToInsert(String tableName)
+ {
+ return recordWorkflowContext.recordsToInsert.get().computeIfAbsent(tableName, (k) -> new ArrayList<>());
+ }
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ @Override
+ public Set getPrimaryKeysToDelete(String tableName)
+ {
+ return recordWorkflowContext.primaryKeysToDelete.get().computeIfAbsent(tableName, (k) -> new HashSet<>());
+ }
+ };
}
@@ -147,81 +165,11 @@ public class RecordWorkflowContext extends WorkflowExecutionContext
/***************************************************************************
- *
+ * allow join-records, which are normally managed by the recordWorkflowContextJoinedRecordHelper
+ * to be explicitly set in that object. Useful for test scenarios, maybe more.
***************************************************************************/
- public List getJoinRecords(QueryJoin queryJoin) throws QException
+ public void setJoinRecords(QueryJoin queryJoin, List associatedRecords)
{
- JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, record.get());
- if(joinRecords.get().get(result.key()) == null)
- {
- ArrayList records = CollectionUtils.useOrWrap(result.makeEmpty() ? Collections.emptyList() : QueryAction.execute(queryJoin.getJoinTable(), result.filter()), new TypeToken<>() {});
- joinRecords.get().put(result.key(), records);
- }
- return joinRecords.get().get(result.key());
- }
-
-
-
- /***************************************************************************
- *
- ***************************************************************************/
- private JoinRecordsKeyConstruction getJoinRecordsKeyConstruction(QueryJoin queryJoin, QRecord mainRecord)
- {
- ArrayList joinValues = new ArrayList<>();
- JoinKey key = new JoinKey(queryJoin.getJoinMetaData().getName(), joinValues);
- String baseTableName = getWorkflow().getTableName();
-
- QQueryFilter filter = new QQueryFilter();
- boolean makeEmpty = false;
-
- for(JoinOn joinOn : queryJoin.getJoinMetaData().getJoinOns())
- {
- Serializable mainTableValue;
- String joinTableField;
- if(queryJoin.getJoinMetaData().getLeftTable().equals(baseTableName))
- {
- mainTableValue = mainRecord.getValue(joinOn.getLeftField());
- joinTableField = joinOn.getRightField();
- }
- else
- {
- mainTableValue = mainRecord.getValue(joinOn.getRightField());
- joinTableField = joinOn.getLeftField();
- }
-
- key.joinValues.add(mainTableValue);
-
- if(mainTableValue == null)
- {
- // throw (new QException("Missing join value in " + baseTableName + " getting related " + tableName + " records"));
- makeEmpty = true;
- }
-
- filter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.EQUALS, mainTableValue));
- }
- JoinRecordsKeyConstruction result = new JoinRecordsKeyConstruction(key, filter, makeEmpty);
- return result;
- }
-
-
-
- /***************************************************************************
- *
- ***************************************************************************/
- private record JoinRecordsKeyConstruction(JoinKey key, QQueryFilter filter, boolean makeEmpty)
- {
-
- }
-
-
-
- /***************************************************************************
- *
- ***************************************************************************/
- public void setJoinRecords(QueryJoin queryJoin, QRecord mainRecord, List records)
- {
- ArrayList recordArrayList = CollectionUtils.useOrWrap(records, new TypeToken<>() {});
- JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, mainRecord);
- joinRecords.get().put(result.key(), recordArrayList);
+ recordWorkflowContextJoinedRecordHelper.get().setJoinRecords(queryJoin, associatedRecords);
}
}
diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContextJoinedRecordHelper.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContextJoinedRecordHelper.java
new file mode 100644
index 0000000..1ad5460
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContextJoinedRecordHelper.java
@@ -0,0 +1,238 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2026. 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.qbits.workflows.implementations.recordworkflows;
+
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import com.google.gson.reflect.TypeToken;
+import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+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;
+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.joins.JoinOn;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+
+
+/*******************************************************************************
+ * For workflows that are centered around a single record and may work with
+ * records joined to that record, an instance of this class can be put into
+ * the workflow's context. It can then be used, specifically, by the
+ * {@link InputRecordFilterStep} to build up a cross-product used to do in-
+ * memory filter operation.
+ *******************************************************************************/
+public class RecordWorkflowContextJoinedRecordHelper implements Serializable
+{
+ private final String tableName;
+ private final QRecord record;
+
+ private Map> joinRecords = new HashMap<>();
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ public RecordWorkflowContextJoinedRecordHelper(String tableName, QRecord record)
+ {
+ this.tableName = tableName;
+ this.record = record;
+ }
+
+
+
+ /***************************************************************************
+ * if the context is collecting records that will be inserted when the
+ * workflow is done, and those need to be included, e.g., for in-memory
+ * filters - then override this method (e.g., via an anonymous inner class
+ * in the specific workflow context class in question), and return appropriate
+ * pending-insertion records here.
+ ***************************************************************************/
+ public List getRecordsToInsert(String tableName)
+ {
+ return Collections.emptyList();
+ }
+
+
+
+ /***************************************************************************
+ * if the context is collecting primary keys that will be deleted when the
+ * workflow is done, and those records need to be excluded, e.g., for in-memory
+ * filters - then override this method (e.g., via an anonymous inner class
+ * in the specific workflow context class in question), and return appropriate
+ * pending-deletion keys here.
+ ***************************************************************************/
+ public Set getPrimaryKeysToDelete(String tableName)
+ {
+ return Collections.emptySet();
+ }
+
+
+
+ /***************************************************************************
+ * Clear the join records map. Specifically useful if the context is being
+ * used both during and after workflow execution (e.g. before and after
+ * records have been stored).
+ ***************************************************************************/
+ public void reset()
+ {
+ joinRecords.clear();
+ }
+
+
+
+ /***************************************************************************
+ * get the join records for a specific query join against the main record
+ * of the workflow.
+ *
+ * internally this method does cache those lookups - see {@code reset()}
+ * to clear that cache.
+ ***************************************************************************/
+ public List getJoinRecords(QueryJoin queryJoin) throws QException
+ {
+ String joinTableOrAlias = queryJoin.getJoinTableOrItsAlias();
+ if(!StringUtils.hasContent(joinTableOrAlias))
+ {
+ throw (new QException("Missing joinTableOrAlias in queryJoin: " + queryJoin));
+ }
+
+ List baseRecords;
+ if(queryJoin.getBaseTableOrAlias().equals(tableName))
+ {
+ baseRecords = List.of(record);
+ }
+ else
+ {
+ baseRecords = Objects.requireNonNullElseGet(joinRecords.get(queryJoin.getBaseTableOrAlias()), ArrayList::new);
+ }
+
+ QQueryFilter joinRecordsFilter = getJoinRecordsFilter(queryJoin, baseRecords);
+ if(joinRecords.get(joinTableOrAlias) == null)
+ {
+ List records = joinRecordsFilter == null ? Collections.emptyList() : QueryAction.execute(queryJoin.getJoinTable(), joinRecordsFilter);
+ joinRecords.put(joinTableOrAlias, records);
+ }
+
+ return joinRecords.get(joinTableOrAlias);
+ }
+
+
+
+ /***************************************************************************
+ * For a given queryJoin and a list of 'base records', build a filter that
+ * can find join records.
+ *
+ * Note that this will potentially be a (a=? AND b=?) OR (a=? AND b=?)
+ * style query...
+ *
+ * @return filter to find join records - or - null if there should be no
+ * join records returned (e.g., empty base record list).
+ ***************************************************************************/
+ protected QQueryFilter getJoinRecordsFilter(QueryJoin queryJoin, List baseRecords)
+ {
+ String baseTableName = queryJoin.getBaseTableOrAlias();
+
+ if(CollectionUtils.nullSafeIsEmpty(baseRecords))
+ {
+ return (null);
+ }
+
+ QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
+ for(QRecord baseRecord : baseRecords)
+ {
+ QQueryFilter subFilter = new QQueryFilter();
+ filter.addSubFilter(subFilter);
+
+ for(JoinOn joinOn : queryJoin.getJoinMetaData().getJoinOns())
+ {
+ Serializable mainTableValue;
+ String joinTableField;
+ if(queryJoin.getJoinMetaData().getLeftTable().equals(baseTableName))
+ {
+ mainTableValue = baseRecord.getValue(joinOn.getLeftField());
+ joinTableField = joinOn.getRightField();
+ }
+ else
+ {
+ mainTableValue = baseRecord.getValue(joinOn.getRightField());
+ joinTableField = joinOn.getLeftField();
+ }
+
+ if(mainTableValue == null)
+ {
+ subFilter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.FALSE));
+ }
+ else
+ {
+ subFilter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.EQUALS, mainTableValue));
+ }
+ }
+ }
+
+ return (filter);
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ public void setJoinRecords(QueryJoin queryJoin, List joinRecords)
+ {
+ ArrayList recordArrayList = CollectionUtils.useOrWrap(joinRecords, new TypeToken<>() {});
+ this.joinRecords.put(queryJoin.getJoinTableOrItsAlias(), recordArrayList);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for tableName
+ **
+ *******************************************************************************/
+ public String getTableName()
+ {
+ return tableName;
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for record
+ **
+ *******************************************************************************/
+ public QRecord getRecord()
+ {
+ return record;
+ }
+}
+
diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java
index bcc5602..4a8e962 100644
--- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java
+++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java
@@ -116,9 +116,10 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface
QueryJoin queryJoin = new QueryJoin()
.withJoinTable(associatedTableName)
+ .withBaseTableOrAlias(tableName)
.withJoinMetaData(QContext.getQInstance().getJoin(association.get().getJoinName()));
- recordWorkflowContext.setJoinRecords(queryJoin, inputRecord, associatedRecords);
+ recordWorkflowContext.setJoinRecords(queryJoin, associatedRecords);
}
}
}
@@ -198,6 +199,12 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface
{
RecordWorkflowContext context = (RecordWorkflowContext) workflowOutput.getContext();
+ /////////////////////////////////////////////////////////////////////////////////////////////////
+ // reset the pre-looked up joins in this object (since after the workflow is complete, records //
+ // will be stored, which wouldn't have been before, so it might have cached old record lists.) //
+ /////////////////////////////////////////////////////////////////////////////////////////////////
+ context.recordWorkflowContextJoinedRecordHelper.get().reset();
+
QRecord record = getRecordForTestAssertionFilter(workflowOutput, assertion);
if(record == null)
{
@@ -210,7 +217,7 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface
// re-use the order filtering logic of InputRecordFilterStep (e.g., building joins) //
//////////////////////////////////////////////////////////////////////////////////////
InputRecordFilterStep inputRecordFilterStep = new InputRecordFilterStep();
- List orderWithJoinedRecords = inputRecordFilterStep.buildCrossProduct(record, filter, context);
+ List orderWithJoinedRecords = InputRecordFilterStep.buildCrossProduct(record, filter, context.recordWorkflowContextJoinedRecordHelper.get());
WorkflowStepOutput workflowStepOutput = inputRecordFilterStep.evaluateCrossProduct(orderWithJoinedRecords, filter);
return (Objects.equals(workflowStepOutput.outputData(), true));
diff --git a/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java b/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java
index 38fa93e..852706a 100644
--- a/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java
+++ b/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java
@@ -136,7 +136,7 @@ public class BaseTest
/***************************************************************************
**
***************************************************************************/
- private QInstance defineQInstance() throws QException
+ protected QInstance defineQInstance() throws QException
{
/////////////////////////////////////
// basic definition of an instance //
diff --git a/src/test/java/com/kingsrook/qbits/workflows/CompanyHierarchyTablesAndJoinsMetaDataProducer.java b/src/test/java/com/kingsrook/qbits/workflows/CompanyHierarchyTablesAndJoinsMetaDataProducer.java
new file mode 100644
index 0000000..e2b4d2d
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/workflows/CompanyHierarchyTablesAndJoinsMetaDataProducer.java
@@ -0,0 +1,324 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2026. 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.qbits.workflows;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
+import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
+import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+
+
+/*******************************************************************************
+ * MetaData Producer that builds several tables and joins, for a mock HR database.
+ *
+ * ER Diagram (joins only):
+ *
+ *
+ * +-------------+
+ * | company |
+ * +-------------+
+ * | ↓ 1:N
+ * |
+ * N:1 ↑ |
+ * +-------------+ → M:1 (manager)
+ * | department |-------------------+
+ * +-------------+ |
+ * | ↓ 1:N |
+ * | |
+ * N:1 ↑ | 1:M ↑ v
+ * +-------------+ 1:1 +----------------+
+ * | employee |---------->| employeeDetail |
+ * +-------------+ +----------------+
+ * ↓ 1:N | ^ \
+ * | | \ M:1 (mentor, self-join)
+ * | +----+
+ * N:1 ↑ |
+ * +-------------+ → M:1 +----------+
+ * | jobHistory |---------->| jobTitle |
+ * +-------------+ 1:M ← +----------+
+ *
+ *******************************************************************************/
+public class CompanyHierarchyTablesAndJoinsMetaDataProducer implements MetaDataProducerInterface
+{
+ public static final String COMPANY_TABLE = "company";
+ public static final String DEPARTMENT_TABLE = "department";
+ public static final String EMPLOYEE_TABLE = "employee";
+ public static final String EMPLOYEE_DETAIL_TABLE = "employeeDetail";
+ public static final String JOB_TITLE_TABLE = "jobTitle";
+ public static final String JOB_HISTORY_TABLE = "jobHistory";
+
+ public static final String COMPANY_JOIN_DEPARTMENT = "companyDepartments";
+ public static final String DEPARTMENT_JOIN_COMPANY = "departmentCompany";
+ public static final String DEPARTMENT_JOIN_EMPLOYEE = "departmentEmployees";
+ public static final String EMPLOYEE_JOIN_DEPARTMENT = "employeeDepartment";
+ public static final String EMPLOYEE_JOIN_EMPLOYEE_AS_MENTOR = "employeeMentor";
+ public static final String EMPLOYEE_JOIN_EMPLOYEE_DETAIL = "employeeEmployeeDetail";
+ public static final String EMPLOYEE_DETAIL_JOIN_EMPLOYEE = "employeeDetailEmployee";
+ public static final String EMPLOYEE_JOIN_JOB_HISTORY = "employeeJobHistories";
+ public static final String JOB_HISTORY_JOIN_EMPLOYEE = "jobHistoryEmployee";
+ public static final String JOB_HISTORY_JOIN_JOB_TITLE = "jobHistoryJobTitle";
+ public static final String JOB_TITLE_JOIN_JOB_HISTORY = "jobTitleJobHistories";
+ public static final String DEPARTMENT_JOIN_MANAGER = "departmentManager";
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ @Override
+ public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
+ {
+ MetaDataProducerMultiOutput rs = new MetaDataProducerMultiOutput();
+
+ ////////////
+ // Tables //
+ ////////////
+
+ /////////////////////////////////////
+ // Company (root of the hierarchy) //
+ /////////////////////////////////////
+ rs.add(new QTableMetaData()
+ .withName(COMPANY_TABLE)
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("name", QFieldType.STRING))
+ .withField(new QFieldMetaData("industry", QFieldType.STRING))
+ .withField(new QFieldMetaData("foundedDate", QFieldType.DATE))
+ .withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_COMPANY, DEPARTMENT_JOIN_EMPLOYEE)))
+ .withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_DETAIL_TABLE).withJoinPath(List.of(COMPANY_JOIN_DEPARTMENT, DEPARTMENT_JOIN_MANAGER, EMPLOYEE_JOIN_EMPLOYEE_DETAIL))));
+
+ //////////////////////////////////////////////////////////////////////////////////
+ // Department (many departments per company; has a manager FK back to employee) //
+ //////////////////////////////////////////////////////////////////////////////////
+ rs.add(new QTableMetaData()
+ .withName(DEPARTMENT_TABLE)
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("companyId", QFieldType.INTEGER)) // FK -> company
+ .withField(new QFieldMetaData("managerId", QFieldType.INTEGER)) // FK -> employee (self-ref via join)
+ .withField(new QFieldMetaData("name", QFieldType.STRING))
+ .withField(new QFieldMetaData("budget", QFieldType.DECIMAL))
+ .withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_DETAIL_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_EMPLOYEE, EMPLOYEE_JOIN_EMPLOYEE_DETAIL)))
+ .withExposedJoin(new ExposedJoin().withJoinTable(JOB_HISTORY_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_MANAGER, EMPLOYEE_JOIN_JOB_HISTORY)))
+ );
+
+ /////////////////////////////////////////////////////////////////////
+ // Employee (many employees per department; 1:1 to EmployeeDetail) //
+ /////////////////////////////////////////////////////////////////////
+ rs.add(new QTableMetaData()
+ .withName(EMPLOYEE_TABLE)
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("departmentId", QFieldType.INTEGER)) // FK -> department
+ .withField(new QFieldMetaData("mentorEmployeeId", QFieldType.INTEGER)) // FK -> employee (self-join)
+ .withField(new QFieldMetaData("firstName", QFieldType.STRING))
+ .withField(new QFieldMetaData("lastName", QFieldType.STRING))
+ .withField(new QFieldMetaData("email", QFieldType.STRING))
+ .withField(new QFieldMetaData("hireDate", QFieldType.DATE))
+ .withField(new QFieldMetaData("isActive", QFieldType.BOOLEAN))
+ .withExposedJoin(new ExposedJoin().withJoinTable(COMPANY_TABLE).withJoinPath(List.of(EMPLOYEE_JOIN_DEPARTMENT, DEPARTMENT_JOIN_COMPANY)))
+ .withExposedJoin(new ExposedJoin().withJoinTable(JOB_TITLE_TABLE).withJoinPath(List.of(EMPLOYEE_JOIN_JOB_HISTORY, JOB_HISTORY_JOIN_JOB_TITLE)))
+ );
+
+ //////////////////////////////////////////////////////////////////
+ // EmployeeDetail (1:1 with employee - extended/sensitive info) //
+ //////////////////////////////////////////////////////////////////
+ rs.add(new QTableMetaData()
+ .withName(EMPLOYEE_DETAIL_TABLE)
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("employeeId", QFieldType.INTEGER)) // FK -> employee (1:1)
+ .withField(new QFieldMetaData("phone", QFieldType.STRING))
+ .withField(new QFieldMetaData("emergencyContact", QFieldType.STRING))
+ .withField(new QFieldMetaData("salary", QFieldType.DECIMAL))
+ .withField(new QFieldMetaData("benefitsTier", QFieldType.STRING)));
+
+ ////////////////////////////////////////////////////////////////////
+ // JobTitle (lookup table - many employees can share a job title) //
+ ////////////////////////////////////////////////////////////////////
+ rs.add(new QTableMetaData()
+ .withName(JOB_TITLE_TABLE)
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("name", QFieldType.STRING))
+ .withField(new QFieldMetaData("grade", QFieldType.STRING))
+ .withField(new QFieldMetaData("isExempt", QFieldType.BOOLEAN)));
+
+ ///////////////////////////////////////////////////////////////////////////////
+ // JobHistory (many job history rows per employee; each row n:1 to jobTitle) //
+ ///////////////////////////////////////////////////////////////////////////////
+ rs.add(new QTableMetaData()
+ .withName(JOB_HISTORY_TABLE)
+ .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
+ .withField(new QFieldMetaData("employeeId", QFieldType.INTEGER)) // FK -> employee
+ .withField(new QFieldMetaData("jobTitleId", QFieldType.INTEGER)) // FK -> jobTitle
+ .withField(new QFieldMetaData("startDate", QFieldType.DATE))
+ .withField(new QFieldMetaData("endDate", QFieldType.DATE))
+ .withField(new QFieldMetaData("salaryAtTime", QFieldType.DECIMAL)));
+
+ /////////////////////////////////////////
+ // set common attributes on all tables //
+ /////////////////////////////////////////
+ rs.getEach(QTableMetaData.class).forEach(t ->
+ {
+ t.setPrimaryKeyField("id");
+ t.setBackendName(BaseTest.MEMORY_BACKEND_NAME);
+ });
+
+ ///////////
+ // Joins //
+ ///////////
+
+ /////////////////////////////////////////////////////////////////
+ // company 1:n department (company.id = department.companyId) //
+ /////////////////////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(COMPANY_JOIN_DEPARTMENT)
+ .withLeftTable(COMPANY_TABLE)
+ .withRightTable(DEPARTMENT_TABLE)
+ .withType(JoinType.ONE_TO_MANY)
+ .withJoinOn(new JoinOn("id", "companyId"))
+ .withOrderBy(new QFilterOrderBy("name")));
+
+ ///////////////////////////////////////////////////////////////////////////////////////////
+ // department n:1 company (flip of above - useful when traversing from department side) //
+ ///////////////////////////////////////////////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(DEPARTMENT_JOIN_COMPANY)
+ .withLeftTable(DEPARTMENT_TABLE)
+ .withRightTable(COMPANY_TABLE)
+ .withType(JoinType.MANY_TO_ONE)
+ .withJoinOn(new JoinOn("companyId", "id")));
+
+ //////////////////////////////////////////////////////////////////////
+ // department 1:n employee (department.id = employee.departmentId) //
+ //////////////////////////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(DEPARTMENT_JOIN_EMPLOYEE)
+ .withLeftTable(DEPARTMENT_TABLE)
+ .withRightTable(EMPLOYEE_TABLE)
+ .withType(JoinType.ONE_TO_MANY)
+ .withJoinOn(new JoinOn("id", "departmentId"))
+ .withOrderBy(new QFilterOrderBy("lastName"))
+ .withOrderBy(new QFilterOrderBy("firstName")));
+
+ //////////////////////////////////////////////
+ // employee n:1 department (flip of above) //
+ //////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(EMPLOYEE_JOIN_DEPARTMENT)
+ .withLeftTable(EMPLOYEE_TABLE)
+ .withRightTable(DEPARTMENT_TABLE)
+ .withType(JoinType.MANY_TO_ONE)
+ .withJoinOn(new JoinOn("departmentId", "id")));
+
+ /////////////////////////////////////////////////
+ // employee n:1 employee (mentor relationship) //
+ /////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(EMPLOYEE_JOIN_EMPLOYEE_AS_MENTOR)
+ .withLeftTable(EMPLOYEE_TABLE) // the mentee
+ .withRightTable(EMPLOYEE_TABLE) // the mentor
+ .withType(JoinType.MANY_TO_ONE)
+ .withJoinOn(new JoinOn("mentorEmployeeId", "id")));
+
+ ////////////////////////////////////////////////////////////////////////////
+ // employee 1:1 employeeDetail (employee.id = employeeDetail.employeeId) //
+ ////////////////////////////////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(EMPLOYEE_JOIN_EMPLOYEE_DETAIL)
+ .withLeftTable(EMPLOYEE_TABLE)
+ .withRightTable(EMPLOYEE_DETAIL_TABLE)
+ .withType(JoinType.ONE_TO_ONE)
+ .withJoinOn(new JoinOn("id", "employeeId")));
+
+ //////////////////////////////////////////////////
+ // employeeDetail 1:1 employee (flip of above) //
+ //////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(EMPLOYEE_DETAIL_JOIN_EMPLOYEE)
+ .withLeftTable(EMPLOYEE_DETAIL_TABLE)
+ .withRightTable(EMPLOYEE_TABLE)
+ .withType(JoinType.ONE_TO_ONE)
+ .withJoinOn(new JoinOn("employeeId", "id")));
+
+ ////////////////////////////////////////////////////////////////////
+ // employee 1:n jobHistory (employee.id = jobHistory.employeeId) //
+ ////////////////////////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(EMPLOYEE_JOIN_JOB_HISTORY)
+ .withLeftTable(EMPLOYEE_TABLE)
+ .withRightTable(JOB_HISTORY_TABLE)
+ .withType(JoinType.ONE_TO_MANY)
+ .withJoinOn(new JoinOn("id", "employeeId"))
+ .withOrderBy(new QFilterOrderBy("startDate", false))); // most recent first
+
+ //////////////////////////////////////////////
+ // jobHistory n:1 employee (flip of above) //
+ //////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(JOB_HISTORY_JOIN_EMPLOYEE)
+ .withLeftTable(JOB_HISTORY_TABLE)
+ .withRightTable(EMPLOYEE_TABLE)
+ .withType(JoinType.MANY_TO_ONE)
+ .withJoinOn(new JoinOn("employeeId", "id")));
+
+ ////////////////////////////////////////////////////////////////////
+ // jobHistory n:1 jobTitle (jobHistory.jobTitleId = jobTitle.id) //
+ ////////////////////////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(JOB_HISTORY_JOIN_JOB_TITLE)
+ .withLeftTable(JOB_HISTORY_TABLE)
+ .withRightTable(JOB_TITLE_TABLE)
+ .withType(JoinType.MANY_TO_ONE)
+ .withJoinOn(new JoinOn("jobTitleId", "id")));
+
+ //////////////////////////////////////////////
+ // jobTitle 1:n jobHistory (flip of above) //
+ //////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(JOB_TITLE_JOIN_JOB_HISTORY)
+ .withLeftTable(JOB_TITLE_TABLE)
+ .withRightTable(JOB_HISTORY_TABLE)
+ .withType(JoinType.ONE_TO_MANY)
+ .withJoinOn(new JoinOn("id", "jobTitleId"))
+ .withOrderBy(new QFilterOrderBy("startDate", false)));
+
+ /////////////////////////////////////////////////////////////////////////////
+ // department n:1 employee (manager) (department.managerId = employee.id) //
+ /////////////////////////////////////////////////////////////////////////////
+ rs.add(new QJoinMetaData()
+ .withName(DEPARTMENT_JOIN_MANAGER)
+ .withLeftTable(DEPARTMENT_TABLE)
+ .withRightTable(EMPLOYEE_TABLE)
+ .withType(JoinType.MANY_TO_ONE)
+ .withJoinOn(new JoinOn("managerId", "id")));
+
+ return (rs);
+ }
+
+}
diff --git a/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStepTest.java b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStepTest.java
new file mode 100644
index 0000000..148549c
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStepTest.java
@@ -0,0 +1,504 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2026. 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.qbits.workflows.implementations.recordworkflows;
+
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+import com.kingsrook.qbits.workflows.BaseTest;
+import com.kingsrook.qbits.workflows.execution.WorkflowStepOutput;
+import com.kingsrook.qbits.workflows.model.Workflow;
+import com.kingsrook.qbits.workflows.model.WorkflowStep;
+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.tables.query.QCriteriaOperator;
+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.utils.JsonUtils;
+import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
+import org.junit.jupiter.api.Test;
+import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.COMPANY_TABLE;
+import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.DEPARTMENT_TABLE;
+import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.EMPLOYEE_DETAIL_TABLE;
+import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.EMPLOYEE_TABLE;
+import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.JOB_HISTORY_TABLE;
+import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.JOB_TITLE_TABLE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*******************************************************************************
+ ** Unit test for InputRecordFilterStep
+ *******************************************************************************/
+class InputRecordFilterStepTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testWithoutJoins() throws QException
+ {
+ //////////////////////////////////////////////////
+ // empty filter matches any record (even empty) //
+ //////////////////////////////////////////////////
+ QQueryFilter emptyFilter = new QQueryFilter();
+ assertOutput(true, EMPLOYEE_TABLE, emptyFilter, new QRecord());
+ assertOutput(true, EMPLOYEE_TABLE, new QQueryFilter(), new QRecord().withValue("id", 1));
+
+ /////////////////////////////////////////////////
+ // simple single criteria - matches or doesn't //
+ /////////////////////////////////////////////////
+ QQueryFilter idEquals1 = new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, 1);
+ assertOutput(false, EMPLOYEE_TABLE, idEquals1, new QRecord());
+ assertOutput(false, EMPLOYEE_TABLE, idEquals1, Map.of("id", 2));
+ assertOutput(true, EMPLOYEE_TABLE, idEquals1, Map.of("id", 1));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testOneToOneJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecord(new QRecord().withValue("employeeId", 1).withValue("phone", "555-1234")));
+
+ QQueryFilter phoneContains555 = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".phone", QCriteriaOperator.CONTAINS, "555");
+ QQueryFilter phoneContains911 = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".phone", QCriteriaOperator.CONTAINS, "911");
+
+ ///////////////////////////////////////////////////////////////////////
+ // employee with detail record with matching phone number is a match //
+ ///////////////////////////////////////////////////////////////////////
+ assertOutput(true, EMPLOYEE_TABLE, phoneContains555, Map.of("id", 1));
+
+ ///////////////////////////////////////////////////////////////////////////////
+ // employee with detail record with non-matching phone number is NOT a match //
+ ///////////////////////////////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, phoneContains911, Map.of("id", 1));
+
+ ///////////////////////////////////////////////////
+ // employee without detail record is NOT a match //
+ ///////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, phoneContains555, Map.of("id", 2));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testManyToOneJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 1).withValue("name", "Engineering"),
+ new QRecord().withValue("id", 2).withValue("name", "Sales"))));
+
+ QQueryFilter departmentIsEngineering = new QQueryFilter().withCriteria(DEPARTMENT_TABLE + ".name", QCriteriaOperator.EQUALS, "Engineering");
+
+ //////////////////////////////////////////////////
+ // employee with matching department is a match //
+ //////////////////////////////////////////////////
+ assertOutput(true, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 1));
+
+ //////////////////////////////////////////////////////////
+ // employee with non-matching department is NOT a match //
+ //////////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 2));
+
+ //////////////////////////////////////////////////
+ // employee without a department is not a match //
+ //////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of());
+ assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, MapBuilder.of("departmentId", null));
+ assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 3));
+ }
+
+
+
+ /*******************************************************************************
+ * employee N:1 department N:1 company
+ *******************************************************************************/
+ @Test
+ void testManyToOneThenManyToOneJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(COMPANY_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 1).withValue("name", "ACME"),
+ new QRecord().withValue("id", 2).withValue("name", "EvilCorp"))));
+
+ new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 11).withValue("companyId", 1).withValue("name", "Engineering"),
+ new QRecord().withValue("id", 12).withValue("companyId", 1).withValue("name", "Sales"),
+ new QRecord().withValue("id", 21).withValue("companyId", 2).withValue("name", "Evil"),
+ new QRecord().withValue("id", 31).withValue("companyId", null).withValue("name", "Company-less"))));
+
+ QQueryFilter companyIsACME = new QQueryFilter().withCriteria(COMPANY_TABLE + ".name", QCriteriaOperator.EQUALS, "ACME");
+
+ ///////////////////////////////////////////////
+ // employee with matching company is a match //
+ ///////////////////////////////////////////////
+ assertOutput(true, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 11));
+
+ //////////////////////////////////////////////////////////
+ // employee with non-matching department is NOT a match //
+ //////////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 21));
+
+ ////////////////////////////////////////////////////////////////////
+ // employee without a department (thus no company) is not a match //
+ ////////////////////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of());
+ assertOutput(false, EMPLOYEE_TABLE, companyIsACME, MapBuilder.of("departmentId", null));
+ assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 3));
+
+ //////////////////////////////////////////////////////////////////////
+ // employee without a department that has no company is not a match //
+ //////////////////////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 31));
+ }
+
+
+
+ /*******************************************************************************
+ ** department 1:N employee 1:1 employeeDetail
+ *******************************************************************************/
+ @Test
+ void testOneToManyThenOneToOneJoin() throws Exception
+ {
+ //////////////////////////////////////////////////////////////////////////////////////
+ // this test queries for Departments, but on fields from EmployeeDetail //
+ // The relationship is Department, one-to-many Employee, one-to-one EmployeeDetail. //
+ //////////////////////////////////////////////////////////////////////////////////////
+ new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 1).withValue("departmentId", 11),
+ new QRecord().withValue("id", 2).withValue("departmentId", 21),
+ new QRecord().withValue("id", 3).withValue("departmentId", 31))));
+
+ new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("salary", new BigDecimal("105000")),
+ new QRecord().withValue("id", 102).withValue("employeeId", 2).withValue("salary", new BigDecimal("95000")))));
+
+ QQueryFilter hasEmployeeOver100K = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".salary", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000"));
+
+ ///////////////////////////////////////////////////////////
+ // department with a matching employee detail is a match //
+ ///////////////////////////////////////////////////////////
+ assertOutput(true, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 11));
+
+ /////////////////////////////////////////////////////////////////////////////////////
+ // department with an employee, who has a detail, but doesn't match is NOT a match //
+ /////////////////////////////////////////////////////////////////////////////////////
+ assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 21));
+
+ /////////////////////////////////////////////////////////////////////
+ // department with no employee, but no employee detail is no match //
+ /////////////////////////////////////////////////////////////////////
+ assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 31));
+
+ /////////////////////////////////////////////
+ // department with no employee is no match //
+ /////////////////////////////////////////////
+ assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 41));
+ }
+
+
+
+ /*******************************************************************************
+ ** employee 1:N jobHistory
+ *******************************************************************************/
+ @Test
+ void testOneToManyJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("120000")),
+ new QRecord().withValue("id", 102).withValue("employeeId", 2).withValue("salaryAtTime", new BigDecimal("90000")))));
+
+ QQueryFilter hasJobHistoryOver100K = new QQueryFilter().withCriteria(JOB_HISTORY_TABLE + ".salaryAtTime", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000"));
+
+ //////////////////////////////////////////////////
+ // employee with a matching job history matches //
+ //////////////////////////////////////////////////
+ assertOutput(true, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 1));
+
+ ///////////////////////////////////////////////////
+ // employee with only non-matching rows does not //
+ ///////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 2));
+
+ ////////////////////////////////////
+ // employee with no rows does not //
+ ////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 3));
+ }
+
+
+
+ /*******************************************************************************
+ ** employee 1:N jobHistory N:1 jobTitle
+ *******************************************************************************/
+ @Test
+ void testOneToManyThenManyToOneJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(JOB_TITLE_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 11).withValue("name", "Engineer"),
+ new QRecord().withValue("id", 12).withValue("name", "Intern"))));
+
+ new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("jobTitleId", 11),
+ new QRecord().withValue("id", 102).withValue("employeeId", 1).withValue("jobTitleId", 12),
+ new QRecord().withValue("id", 201).withValue("employeeId", 2).withValue("jobTitleId", 12),
+ new QRecord().withValue("id", 301).withValue("employeeId", 3).withValue("jobTitleId", null))));
+
+ QQueryFilter hasHadEngineerTitle = new QQueryFilter().withCriteria(JOB_TITLE_TABLE + ".name", QCriteriaOperator.EQUALS, "Engineer");
+ QQueryFilter hasHadInternTitle = new QQueryFilter().withCriteria(JOB_TITLE_TABLE + ".name", QCriteriaOperator.EQUALS, "Intern");
+
+ ////////////////////////////////////////////////////////////
+ // employee with multiple matching titles through history //
+ ////////////////////////////////////////////////////////////
+ assertOutput(true, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 1));
+ assertOutput(true, EMPLOYEE_TABLE, hasHadInternTitle, Map.of("id", 1));
+
+ /////////////////////////////////////////////////////////
+ // employee with single matching title through history //
+ /////////////////////////////////////////////////////////
+ assertOutput(true, EMPLOYEE_TABLE, hasHadInternTitle, Map.of("id", 1));
+
+ //////////////////////////////////////////////////////
+ // employee with non-matching title through history //
+ //////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 2));
+
+ ////////////////////////////////////////////////////////
+ // employee with job history but missing title target //
+ ////////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 3));
+
+ //////////////////////////////////
+ // employee with no job history //
+ //////////////////////////////////
+ assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 4));
+ }
+
+
+
+ /*******************************************************************************
+ ** company 1:N department 1:N employee
+ *******************************************************************************/
+ @Test
+ void testOneToManyThenOneToManyJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 11).withValue("companyId", 1).withValue("name", "Engineering"),
+ new QRecord().withValue("id", 12).withValue("companyId", 1).withValue("name", "Support"),
+ new QRecord().withValue("id", 21).withValue("companyId", 2).withValue("name", "Support"),
+ new QRecord().withValue("id", 31).withValue("companyId", 3).withValue("name", "Safety"),
+ new QRecord().withValue("id", 41).withValue("companyId", 4).withValue("name", "Empty Department"))));
+
+ new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
+ new QRecord().withValue("departmentId", 11).withValue("lastName", "Smith").withValue("firstName", "Ozzie"),
+ new QRecord().withValue("departmentId", 12).withValue("lastName", "Smith").withValue("firstName", "Will"),
+ new QRecord().withValue("departmentId", 11).withValue("lastName", "Jones"),
+ new QRecord().withValue("departmentId", 21).withValue("lastName", "Jones"),
+ new QRecord().withValue("departmentId", 31).withValue("lastName", "Simpson"),
+ new QRecord().withValue("departmentId", 21).withValue("lastName", "Taylor"))));
+
+ QQueryFilter hasEmployeeNamedSmith = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Smith");
+ QQueryFilter hasEmployeeNamedSimpson = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Simpson");
+
+ /////////////////////////////////////////////////
+ // company with at least one matching employee //
+ /////////////////////////////////////////////////
+ assertOutput(true, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 1));
+ assertOutput(true, COMPANY_TABLE, hasEmployeeNamedSimpson, Map.of("id", 3));
+
+ //////////////////////////////////////////////
+ // company with employees but none matching //
+ //////////////////////////////////////////////
+ assertOutput(false, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 2));
+
+ ///////////////////////////////////////////////
+ // company with departments but no employees //
+ ///////////////////////////////////////////////
+ assertOutput(false, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 4));
+ }
+
+
+
+ /*******************************************************************************
+ ** department N:1 manager(employee) 1:N jobHistory
+ *******************************************************************************/
+ @Test
+ void testManyToOneThenOneToManyJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 1).withValue("lastName", "Smith"),
+ new QRecord().withValue("id", 2).withValue("lastName", "Jones"),
+ new QRecord().withValue("id", 3).withValue("lastName", "Milton")
+ )));
+
+ new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 301).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("130000")),
+ new QRecord().withValue("id", 302).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("120000")),
+ new QRecord().withValue("id", 303).withValue("employeeId", 2).withValue("salaryAtTime", new BigDecimal("80000")))));
+
+ QQueryFilter managerHasHistoryOver100K = new QQueryFilter().withCriteria(JOB_HISTORY_TABLE + ".salaryAtTime", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000"));
+
+ //////////////////////////////////////////////////////////
+ // department whose manager has matching history row(s) //
+ //////////////////////////////////////////////////////////
+ assertOutput(true, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 1));
+
+ /////////////////////////////////////////////////////////
+ // department whose manager has only non-matching rows //
+ /////////////////////////////////////////////////////////
+ assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 2));
+
+ ////////////////////////////////////////////////////////////
+ // department with null or unknown manager does not match //
+ ////////////////////////////////////////////////////////////
+ assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of());
+ assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 99));
+
+ //////////////////////////////////////////////////
+ // manager who has no job history doesn't match //
+ //////////////////////////////////////////////////
+ assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 3));
+ }
+
+
+
+ /*******************************************************************************
+ ** employeeDetail 1:1 employee (reverse direction of employee->employeeDetail)
+ *******************************************************************************/
+ @Test
+ void testOneToOneReverseJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 100).withValue("lastName", "Smith"),
+ new QRecord().withValue("id", 101).withValue("lastName", "Jones"))));
+
+ QQueryFilter employeeLastNameIsSmith = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Smith");
+
+ /////////////////////////////////////////////////////
+ // detail row linked to matching employee is match //
+ /////////////////////////////////////////////////////
+ assertOutput(true, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 100));
+
+ /////////////////////////////////////////////////////////
+ // detail row linked to non-matching employee no match //
+ /////////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 101));
+
+ ////////////////////////////////////////////////////
+ // detail row with null/unknown employee no match //
+ ////////////////////////////////////////////////////
+ assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, MapBuilder.of("employeeId", null));
+ assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 999));
+ }
+
+
+
+ /*******************************************************************************
+ ** company 1:N department N:1 manager(employee) 1:1 employeeDetail
+ *******************************************************************************/
+ @Test
+ void testOneToManyThenManyToOneThenOneToOneJoin() throws Exception
+ {
+ new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 11).withValue("lastName", "Smith"),
+ new QRecord().withValue("id", 21).withValue("lastName", "Jones"),
+ new QRecord().withValue("id", 31).withValue("lastName", "Milton")
+ )));
+
+ new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 401).withValue("companyId", 1).withValue("managerId", 11).withValue("name", "Engineering"),
+ new QRecord().withValue("id", 402).withValue("companyId", 2).withValue("managerId", 21).withValue("name", "Support"),
+ new QRecord().withValue("id", 403).withValue("companyId", 3).withValue("managerId", 31).withValue("name", "Office Space"),
+ new QRecord().withValue("id", 404).withValue("companyId", 4).withValue("managerId", null).withValue("name", "No Manager"))));
+
+ new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecords(List.of(
+ new QRecord().withValue("id", 501).withValue("employeeId", 11).withValue("benefitsTier", "PLATINUM"),
+ new QRecord().withValue("id", 502).withValue("employeeId", 21).withValue("benefitsTier", "BASIC"))));
+
+ QQueryFilter hasManagerWithPlatinumTier = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".benefitsTier", QCriteriaOperator.EQUALS, "PLATINUM");
+
+ ///////////////////////////////////////////////////////////////
+ // company with department whose manager has matching detail //
+ ///////////////////////////////////////////////////////////////
+ assertOutput(true, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 1));
+
+ ///////////////////////////////////////////////////////////////
+ // company with department whose manager detail is non-match //
+ ///////////////////////////////////////////////////////////////
+ assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 2));
+
+ ///////////////////////////////////////////////////
+ // company with department whose manager is null //
+ ///////////////////////////////////////////////////
+ assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 4));
+
+ //////////////////////////////////////////////////////////
+ // company with department whose manager has no history //
+ //////////////////////////////////////////////////////////
+ assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 3));
+
+ /////////////////////////////////////////////
+ // company with no departments is no match //
+ /////////////////////////////////////////////
+ assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 5));
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, Map recordValues) throws QException
+ {
+ QRecord record = new QRecord();
+ record.setValues(recordValues);
+ assertOutput(expectedOutput, tableName, filter, record);
+ }
+
+
+
+ /***************************************************************************
+ *
+ ***************************************************************************/
+ void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, QRecord record) throws QException
+ {
+ WorkflowStep step = new WorkflowStep();
+ Map inputValues = Map.of("queryFilterJson", JsonUtils.toJson(filter));
+
+ RecordWorkflowContext context = new RecordWorkflowContext();
+ context.setWorkflow(new Workflow().withTableName(tableName));
+ context.record.set(record);
+ record.setTableName(tableName);
+
+ WorkflowStepOutput stepOutput = new InputRecordFilterStep().execute(step, inputValues, context);
+ assertEquals(expectedOutput, stepOutput.outputData());
+ }
+}