Feature/follow all exposed joins in input record filter step #6

Merged
darinkelkhoff merged 6 commits from feature/follow-all-exposed-joins-in-input-record-filter-step into develop 2026-03-24 21:37:14 +00:00
9 changed files with 1242 additions and 118 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.3.12
qqq-orb: kingsrook/qqq-orb@0.6
workflows:
test_only:
+4 -1
View File
@@ -29,7 +29,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>1.4.0</version>
<version>1.5.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -73,15 +73,18 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.40.0-join-enhancements-24cb9791-20260216.220349-1</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-api</artifactId>
<version>0.40.0-join-enhancements-24cb9791-20260216.220349-1</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<version>0.40.0-join-enhancements-24cb9791-20260216.220349-1</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -25,8 +25,10 @@ package com.kingsrook.qbits.workflows.implementations.recordworkflows;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.workflows.definition.OutboundLinkMode;
import com.kingsrook.qbits.workflows.definition.OutboundLinkOption;
import com.kingsrook.qbits.workflows.definition.WorkflowStepType;
@@ -54,6 +56,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.utils.BackendQueryFilterUtils;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
@@ -106,8 +109,10 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
**
***************************************************************************/
@Override
public WorkflowStepOutput execute(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext context) throws QException
public WorkflowStepOutput execute(WorkflowStep step, Map<String, Serializable> inputValues, WorkflowExecutionContext workflowExecutionContext) throws QException
{
RecordWorkflowContext context = (RecordWorkflowContext) workflowExecutionContext;
QRecord record = (QRecord) context.getValues().get("record");
if(record == null)
{
@@ -130,26 +135,38 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS
///////////////////////////////////////////////////////////////////////////////
filter.applyCriteriaOptionToAllCriteria(CriteriaOption.CASE_INSENSITIVE);
List<QRecordWithJoinedRecords> recordWithJoins = buildCrossProduct(record, filter, context);
List<QRecordWithJoinedRecords> recordWithJoins = buildCrossProduct(record, filter, context.recordWorkflowContextJoinedRecordHelper.get());
return evaluateCrossProduct(recordWithJoins, filter);
}
/***************************************************************************
* For a given record, and a filter, and a {@link RecordWorkflowContextJoinedRecordHelper}
* object from a live workflow context, build a List of {@link QRecordWithJoinedRecords}
* that can be used to evaluate if a record (with joins) matches a filter.
*
* <p>Note that this method is public, and called by application-defined workflows.
* In other words, this method is part of our published API in here!</p>
*
* @param record the main record in the workflow
* @param filter the filter which may have joins (thus the need for a cross-product)
* @param joinedRecordHelper object from context of the workflow that knows
* about cached join records and records being inserted
* and/or deleted.
* @return {@code List<QRecordWithJoinedRecords>} the cross product that the
* filter should be evaluated against.
***************************************************************************/
public List<QRecordWithJoinedRecords> buildCrossProduct(QRecord record, QQueryFilter filter, WorkflowExecutionContext workflowExecutionContext) throws QException
public static List<QRecordWithJoinedRecords> buildCrossProduct(QRecord record, QQueryFilter filter, RecordWorkflowContextJoinedRecordHelper joinedRecordHelper) throws QException
{
RecordWorkflowContext context = (RecordWorkflowContext) workflowExecutionContext;
List<QRecordWithJoinedRecords> crossProduct = new ArrayList<>();
crossProduct.add(new QRecordWithJoinedRecords(record));
List<QueryJoin> joinsInFilter = BackendQueryFilterUtils.identifyJoinsInFilter(context.getWorkflow().getTableName(), filter);
List<QueryJoin> 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.
*
* <p>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'.</p>
*
* <p><i>Note that this method was copied from {@code AbstractRDBMSAction}</i></p>
*******************************************************************************/
private static List<QueryJoin> sortQueryJoinsFromMainTableOutward(String mainTableName, List<QueryJoin> queryJoins)
{
List<QueryJoin> rs = new ArrayList<>();
////////////////////////////////////////////////////////////////////////////////
// make a copy of the input list that we can feel safe removing elements from //
////////////////////////////////////////////////////////////////////////////////
List<QueryJoin> inputListCopy = new ArrayList<>(queryJoins);
///////////////////////////////////////////////////////////////////////////////////////////////////
// keep track of the tables (or aliases) that we've seen - that's what we'll "grow" outward from //
///////////////////////////////////////////////////////////////////////////////////////////////////
Set<String> 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<QueryJoin> 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<QRecordWithJoinedRecords> expandCrossProductViaJoin(List<QRecordWithJoinedRecords> orderWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContext context) throws QException
private static List<QRecordWithJoinedRecords> expandCrossProductViaJoin(List<QRecordWithJoinedRecords> recordWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContextJoinedRecordHelper joinedRecordHelper) throws QException
{
String joinTableName = queryJoin.getJoinTable();
ArrayList<QRecord> recordsToBeInserted = context.recordsToInsert.get().computeIfAbsent(joinTableName, k -> new ArrayList<>());
List<QRecord> recordsAlreadyInBackend = context.getJoinRecords(queryJoin);
HashSet<Serializable> idsToDelete = context.primaryKeysToDelete.get().computeIfAbsent(joinTableName, k -> new HashSet<>());
String primaryKeyField = QContext.getQInstance().getTable(joinTableName).getPrimaryKeyField();
String joinTableName = queryJoin.getJoinTable();
List<QRecord> recordsToBeInserted = joinedRecordHelper.getRecordsToInsert(joinTableName);
List<QRecord> recordsAlreadyInBackend = joinedRecordHelper.getJoinRecords(queryJoin);
Set<Serializable> 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<QRecordWithJoinedRecords> makeCrossProduct(List<QRecordWithJoinedRecords> orderWithJoinedRecords, String joinTableName, List<QRecord> joinRecodsToCross)
private static List<QRecordWithJoinedRecords> makeCrossProduct(List<QRecordWithJoinedRecords> recordsWithJoinedRecords, String joinTableName, List<QRecord> joinRecordsToCross)
{
if(!joinRecodsToCross.isEmpty())
if(recordsWithJoinedRecords.isEmpty() || joinRecordsToCross.isEmpty())
{
List<QRecordWithJoinedRecords> newCrossProduct = new ArrayList<>();
for(QRecordWithJoinedRecords orderWithJoinedRecord : orderWithJoinedRecords)
{
newCrossProduct.addAll(orderWithJoinedRecord.buildCrossProduct(joinTableName, joinRecodsToCross));
}
orderWithJoinedRecords = newCrossProduct;
return new ArrayList<>();
}
return orderWithJoinedRecords;
List<QRecordWithJoinedRecords> newCrossProduct = new ArrayList<>();
for(QRecordWithJoinedRecords recordWithJoinedRecord : recordsWithJoinedRecords)
{
newCrossProduct.addAll(recordWithJoinedRecord.buildCrossProduct(joinTableName, joinRecordsToCross));
}
recordsWithJoinedRecords = newCrossProduct;
return recordsWithJoinedRecords;
}
@@ -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<HashMap<String, ArrayList<QRecord>>> recordsToInsert = new ObjectInWorkflowContext<>(this, "recordsToInsert", new HashMap<>());
public final ObjectInWorkflowContext<HashMap<String, HashSet<Serializable>>> 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<HashMap<JoinKey, ArrayList<QRecord>>> 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> recordWorkflowContextJoinedRecordHelper = new LazyInitObjectInWorkflowContext<>(this, "recordWorkflowContextJoinedRecordHelper", () -> newRecordWorkflowContextJoinedRecordHelper(this));
private record JoinKey(String joinName, ArrayList<Serializable> 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<QRecord> getRecordsToInsert(String tableName)
{
return recordWorkflowContext.recordsToInsert.get().computeIfAbsent(tableName, (k) -> new ArrayList<>());
}
/***************************************************************************
*
***************************************************************************/
@Override
public Set<Serializable> 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<QRecord> getJoinRecords(QueryJoin queryJoin) throws QException
public void setJoinRecords(QueryJoin queryJoin, List<QRecord> associatedRecords)
{
JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, record.get());
if(joinRecords.get().get(result.key()) == null)
{
ArrayList<QRecord> 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<Serializable> 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<QRecord> records)
{
ArrayList<QRecord> recordArrayList = CollectionUtils.useOrWrap(records, new TypeToken<>() {});
JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, mainRecord);
joinRecords.get().put(result.key(), recordArrayList);
recordWorkflowContextJoinedRecordHelper.get().setJoinRecords(queryJoin, associatedRecords);
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, List<QRecord>> 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<QRecord> 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<Serializable> 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.
*
* <p>internally this method does cache those lookups - see {@code reset()}
* to clear that cache.</p>
***************************************************************************/
public List<QRecord> getJoinRecords(QueryJoin queryJoin) throws QException
{
String joinTableOrAlias = queryJoin.getJoinTableOrItsAlias();
if(!StringUtils.hasContent(joinTableOrAlias))
{
throw (new QException("Missing joinTableOrAlias in queryJoin: " + queryJoin));
}
List<QRecord> 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<QRecord> 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.
*
* <p>Note that this will potentially be a (a=? AND b=?) OR (a=? AND b=?)
* style query... </p>
*
* @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<QRecord> 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<QRecord> joinRecords)
{
ArrayList<QRecord> 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;
}
}
@@ -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<QRecordWithJoinedRecords> orderWithJoinedRecords = inputRecordFilterStep.buildCrossProduct(record, filter, context);
List<QRecordWithJoinedRecords> orderWithJoinedRecords = InputRecordFilterStep.buildCrossProduct(record, filter, context.recordWorkflowContextJoinedRecordHelper.get());
WorkflowStepOutput workflowStepOutput = inputRecordFilterStep.evaluateCrossProduct(orderWithJoinedRecords, filter);
return (Objects.equals(workflowStepOutput.outputData(), true));
@@ -136,7 +136,7 @@ public class BaseTest
/***************************************************************************
**
***************************************************************************/
private QInstance defineQInstance() throws QException
protected QInstance defineQInstance() throws QException
{
/////////////////////////////////////
// basic definition of an instance //
@@ -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 <https://www.gnu.org/licenses/>.
*/
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.
*
* <p>ER Diagram (joins only):</p>
*
* <pre>
* +-------------+
* | 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 ← +----------+
* </pre>
*******************************************************************************/
public class CompanyHierarchyTablesAndJoinsMetaDataProducer implements MetaDataProducerInterface<MetaDataProducerMultiOutput>
{
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);
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, Serializable> 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<String, Serializable> 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());
}
}