Feature/follow all exposed joins in input record filter step #6
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
+124
-24
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+37
-89
@@ -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);
|
||||
}
|
||||
}
|
||||
+238
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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 //
|
||||
|
||||
+324
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+504
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user