diff --git a/.circleci/config.yml b/.circleci/config.yml index bb9c619..c8d76b5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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: diff --git a/pom.xml b/pom.xml index 5b5eb8b..adbb2e0 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ com.kingsrook qbit-build-parent - 1.4.0 + 1.5.1 com.kingsrook.qbits @@ -73,15 +73,18 @@ com.kingsrook.qqq qqq-backend-core + 0.40.0-join-enhancements-24cb9791-20260216.220349-1 com.kingsrook.qqq qqq-middleware-api + 0.40.0-join-enhancements-24cb9791-20260216.220349-1 true com.kingsrook.qqq qqq-backend-module-rdbms + 0.40.0-join-enhancements-24cb9791-20260216.220349-1 test diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStep.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStep.java index 232749b..26fb03e 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStep.java +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStep.java @@ -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 inputValues, WorkflowExecutionContext context) throws QException + public WorkflowStepOutput execute(WorkflowStep step, Map 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 recordWithJoins = buildCrossProduct(record, filter, context); + List 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. * + *

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!

+ * + * @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} the cross product that the + * filter should be evaluated against. ***************************************************************************/ - public List buildCrossProduct(QRecord record, QQueryFilter filter, WorkflowExecutionContext workflowExecutionContext) throws QException + public static List buildCrossProduct(QRecord record, QQueryFilter filter, RecordWorkflowContextJoinedRecordHelper joinedRecordHelper) throws QException { - RecordWorkflowContext context = (RecordWorkflowContext) workflowExecutionContext; - List crossProduct = new ArrayList<>(); crossProduct.add(new QRecordWithJoinedRecords(record)); - List joinsInFilter = BackendQueryFilterUtils.identifyJoinsInFilter(context.getWorkflow().getTableName(), filter); + List joinsInFilter = BackendQueryFilterUtils.identifyJoinsInFilter(joinedRecordHelper.getTableName(), filter); + joinsInFilter = sortQueryJoinsFromMainTableOutward(joinedRecordHelper.getTableName(), joinsInFilter); for(QueryJoin join : joinsInFilter) { - crossProduct = expandCrossProductViaJoin(crossProduct, join, context); + crossProduct = expandCrossProductViaJoin(crossProduct, join, joinedRecordHelper); } return crossProduct; @@ -157,16 +174,96 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS + /******************************************************************************* + * Sort the query joins so that they fan outward from the main table to ones + * farther away. + * + *

This is so, if we've got a -> b -> c -- we'll process 'b' before 'c', + * as we need the records in 'b' to find the records in 'c'.

+ * + *

Note that this method was copied from {@code AbstractRDBMSAction}

+ *******************************************************************************/ + private static List sortQueryJoinsFromMainTableOutward(String mainTableName, List queryJoins) + { + List rs = new ArrayList<>(); + + //////////////////////////////////////////////////////////////////////////////// + // make a copy of the input list that we can feel safe removing elements from // + //////////////////////////////////////////////////////////////////////////////// + List inputListCopy = new ArrayList<>(queryJoins); + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // keep track of the tables (or aliases) that we've seen - that's what we'll "grow" outward from // + /////////////////////////////////////////////////////////////////////////////////////////////////// + Set seenTablesOrAliases = new HashSet<>(); + seenTablesOrAliases.add(mainTableName); + + //////////////////////////////////////////////////////////////////////////////////// + // loop as long as there are more tables in the inputList, and the keepGoing flag // + // is set (e.g., indicating that we added something in the last iteration) // + //////////////////////////////////////////////////////////////////////////////////// + boolean keepGoing = true; + while(!inputListCopy.isEmpty() && keepGoing) + { + keepGoing = false; + + Iterator iterator = inputListCopy.iterator(); + while(iterator.hasNext()) + { + QueryJoin nextQueryJoin = iterator.next(); + + ////////////////////////////////////////////////////////////////////////// + // get the baseTableOrAlias from this join - and if it isn't set in the // + // QueryJoin, then get it from the left-side of the join's metaData // + ////////////////////////////////////////////////////////////////////////// + String baseTableOrAlias = nextQueryJoin.getBaseTableOrAlias(); + if(baseTableOrAlias == null && nextQueryJoin.getJoinMetaData() != null) + { + baseTableOrAlias = nextQueryJoin.getJoinMetaData().getLeftTable(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if we have a baseTableOrAlias (would we ever not?), and we've seen it before - OR - we've seen this query join's joinTableOrAlias, // + // then we can add this pair of namesOrAliases to our seen-set, remove this queryJoin from the inputListCopy (iterator), and keep going // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if((StringUtils.hasContent(baseTableOrAlias) && seenTablesOrAliases.contains(baseTableOrAlias)) || seenTablesOrAliases.contains(nextQueryJoin.getJoinTableOrItsAlias())) + { + rs.add(nextQueryJoin); + if(StringUtils.hasContent(baseTableOrAlias)) + { + seenTablesOrAliases.add(baseTableOrAlias); + } + + seenTablesOrAliases.add(nextQueryJoin.getJoinTableOrItsAlias()); + iterator.remove(); + keepGoing = true; + } + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // in case any are left, add them all here - does this ever happen? // + // the only time a conditional breakpoint here fires in the RDBMS test suite, is in query designed to throw. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + rs.addAll(inputListCopy); + + return (rs); + } + + + + + /*************************************************************************** * ***************************************************************************/ - private static List expandCrossProductViaJoin(List orderWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContext context) throws QException + private static List expandCrossProductViaJoin(List recordWithJoinedRecords, QueryJoin queryJoin, RecordWorkflowContextJoinedRecordHelper joinedRecordHelper) throws QException { - String joinTableName = queryJoin.getJoinTable(); - ArrayList recordsToBeInserted = context.recordsToInsert.get().computeIfAbsent(joinTableName, k -> new ArrayList<>()); - List recordsAlreadyInBackend = context.getJoinRecords(queryJoin); - HashSet idsToDelete = context.primaryKeysToDelete.get().computeIfAbsent(joinTableName, k -> new HashSet<>()); - String primaryKeyField = QContext.getQInstance().getTable(joinTableName).getPrimaryKeyField(); + String joinTableName = queryJoin.getJoinTable(); + List recordsToBeInserted = joinedRecordHelper.getRecordsToInsert(joinTableName); + List recordsAlreadyInBackend = joinedRecordHelper.getJoinRecords(queryJoin); + Set idsToDelete = joinedRecordHelper.getPrimaryKeysToDelete(joinTableName); + String primaryKeyField = QContext.getQInstance().getTable(joinTableName).getPrimaryKeyField(); ////////////////////////////////////////////////////////////////////////////////////////////////////// // add records that already existed to the cross product, filtering out ones that are to be deleted // @@ -185,8 +282,8 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS //////////////////////////////////// CollectionUtils.addAllIfNotNull(recordsToCross, recordsToBeInserted); - orderWithJoinedRecords = makeCrossProduct(orderWithJoinedRecords, joinTableName, recordsToCross); - return orderWithJoinedRecords; + recordWithJoinedRecords = makeCrossProduct(recordWithJoinedRecords, joinTableName, recordsToCross); + return recordWithJoinedRecords; } @@ -194,18 +291,21 @@ public class InputRecordFilterStep extends WorkflowStepType implements WorkflowS /*************************************************************************** * ***************************************************************************/ - private static List makeCrossProduct(List orderWithJoinedRecords, String joinTableName, List joinRecodsToCross) + private static List makeCrossProduct(List recordsWithJoinedRecords, String joinTableName, List joinRecordsToCross) { - if(!joinRecodsToCross.isEmpty()) + if(recordsWithJoinedRecords.isEmpty() || joinRecordsToCross.isEmpty()) { - List newCrossProduct = new ArrayList<>(); - for(QRecordWithJoinedRecords orderWithJoinedRecord : orderWithJoinedRecords) - { - newCrossProduct.addAll(orderWithJoinedRecord.buildCrossProduct(joinTableName, joinRecodsToCross)); - } - orderWithJoinedRecords = newCrossProduct; + return new ArrayList<>(); } - return orderWithJoinedRecords; + + List newCrossProduct = new ArrayList<>(); + for(QRecordWithJoinedRecords recordWithJoinedRecord : recordsWithJoinedRecords) + { + newCrossProduct.addAll(recordWithJoinedRecord.buildCrossProduct(joinTableName, joinRecordsToCross)); + } + recordsWithJoinedRecords = newCrossProduct; + + return recordsWithJoinedRecords; } diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java index 0d23764..a2dd14a 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContext.java @@ -25,24 +25,16 @@ package com.kingsrook.qbits.workflows.implementations.recordworkflows; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Set; -import com.google.gson.reflect.TypeToken; +import com.kingsrook.qbits.workflows.execution.LazyInitObjectInWorkflowContext; import com.kingsrook.qbits.workflows.execution.ObjectInWorkflowContext; import com.kingsrook.qbits.workflows.execution.WorkflowExecutionContext; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; -import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; -import com.kingsrook.qqq.backend.core.utils.CollectionUtils; /******************************************************************************* @@ -57,17 +49,43 @@ public class RecordWorkflowContext extends WorkflowExecutionContext public final ObjectInWorkflowContext>> recordsToInsert = new ObjectInWorkflowContext<>(this, "recordsToInsert", new HashMap<>()); public final ObjectInWorkflowContext>> primaryKeysToDelete = new ObjectInWorkflowContext<>(this, "primaryKeysToDelete", new HashMap<>()); - /////////////////////////////////////////////////////////////////////////////////////////// - // records already stored in the backend that are joined with the main record. // - // should only be accessed via the getRelatedRecords method, which lazy inits (by query) // - /////////////////////////////////////////////////////////////////////////////////////////// - private final ObjectInWorkflowContext>> joinRecords = new ObjectInWorkflowContext<>(this, "joinRecords", new HashMap<>()); + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // records already stored in the backend that are joined with the main record - they used to be stored in a map kept // + // right here, but they've migrated into the RecordWorkflowContextJoinedRecordHelper. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + public final ObjectInWorkflowContext recordWorkflowContextJoinedRecordHelper = new LazyInitObjectInWorkflowContext<>(this, "recordWorkflowContextJoinedRecordHelper", () -> newRecordWorkflowContextJoinedRecordHelper(this)); - private record JoinKey(String joinName, ArrayList joinValues) + /*************************************************************************** + * Construct a new RecordWorkflowContextJoinedRecordHelper, which uses this + * context class's members to get the list of records to insert and the + * set of ids to delete + ***************************************************************************/ + private RecordWorkflowContextJoinedRecordHelper newRecordWorkflowContextJoinedRecordHelper(RecordWorkflowContext recordWorkflowContext) { + return new RecordWorkflowContextJoinedRecordHelper(getWorkflow().getTableName(), record.get()) + { + /*************************************************************************** + * + ***************************************************************************/ + @Override + public List getRecordsToInsert(String tableName) + { + return recordWorkflowContext.recordsToInsert.get().computeIfAbsent(tableName, (k) -> new ArrayList<>()); + } + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public Set getPrimaryKeysToDelete(String tableName) + { + return recordWorkflowContext.primaryKeysToDelete.get().computeIfAbsent(tableName, (k) -> new HashSet<>()); + } + }; } @@ -147,81 +165,11 @@ public class RecordWorkflowContext extends WorkflowExecutionContext /*************************************************************************** - * + * allow join-records, which are normally managed by the recordWorkflowContextJoinedRecordHelper + * to be explicitly set in that object. Useful for test scenarios, maybe more. ***************************************************************************/ - public List getJoinRecords(QueryJoin queryJoin) throws QException + public void setJoinRecords(QueryJoin queryJoin, List associatedRecords) { - JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, record.get()); - if(joinRecords.get().get(result.key()) == null) - { - ArrayList records = CollectionUtils.useOrWrap(result.makeEmpty() ? Collections.emptyList() : QueryAction.execute(queryJoin.getJoinTable(), result.filter()), new TypeToken<>() {}); - joinRecords.get().put(result.key(), records); - } - return joinRecords.get().get(result.key()); - } - - - - /*************************************************************************** - * - ***************************************************************************/ - private JoinRecordsKeyConstruction getJoinRecordsKeyConstruction(QueryJoin queryJoin, QRecord mainRecord) - { - ArrayList joinValues = new ArrayList<>(); - JoinKey key = new JoinKey(queryJoin.getJoinMetaData().getName(), joinValues); - String baseTableName = getWorkflow().getTableName(); - - QQueryFilter filter = new QQueryFilter(); - boolean makeEmpty = false; - - for(JoinOn joinOn : queryJoin.getJoinMetaData().getJoinOns()) - { - Serializable mainTableValue; - String joinTableField; - if(queryJoin.getJoinMetaData().getLeftTable().equals(baseTableName)) - { - mainTableValue = mainRecord.getValue(joinOn.getLeftField()); - joinTableField = joinOn.getRightField(); - } - else - { - mainTableValue = mainRecord.getValue(joinOn.getRightField()); - joinTableField = joinOn.getLeftField(); - } - - key.joinValues.add(mainTableValue); - - if(mainTableValue == null) - { - // throw (new QException("Missing join value in " + baseTableName + " getting related " + tableName + " records")); - makeEmpty = true; - } - - filter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.EQUALS, mainTableValue)); - } - JoinRecordsKeyConstruction result = new JoinRecordsKeyConstruction(key, filter, makeEmpty); - return result; - } - - - - /*************************************************************************** - * - ***************************************************************************/ - private record JoinRecordsKeyConstruction(JoinKey key, QQueryFilter filter, boolean makeEmpty) - { - - } - - - - /*************************************************************************** - * - ***************************************************************************/ - public void setJoinRecords(QueryJoin queryJoin, QRecord mainRecord, List records) - { - ArrayList recordArrayList = CollectionUtils.useOrWrap(records, new TypeToken<>() {}); - JoinRecordsKeyConstruction result = getJoinRecordsKeyConstruction(queryJoin, mainRecord); - joinRecords.get().put(result.key(), recordArrayList); + recordWorkflowContextJoinedRecordHelper.get().setJoinRecords(queryJoin, associatedRecords); } } diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContextJoinedRecordHelper.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContextJoinedRecordHelper.java new file mode 100644 index 0000000..1ad5460 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowContextJoinedRecordHelper.java @@ -0,0 +1,238 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.implementations.recordworkflows; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import com.google.gson.reflect.TypeToken; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + * For workflows that are centered around a single record and may work with + * records joined to that record, an instance of this class can be put into + * the workflow's context. It can then be used, specifically, by the + * {@link InputRecordFilterStep} to build up a cross-product used to do in- + * memory filter operation. + *******************************************************************************/ +public class RecordWorkflowContextJoinedRecordHelper implements Serializable +{ + private final String tableName; + private final QRecord record; + + private Map> joinRecords = new HashMap<>(); + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public RecordWorkflowContextJoinedRecordHelper(String tableName, QRecord record) + { + this.tableName = tableName; + this.record = record; + } + + + + /*************************************************************************** + * if the context is collecting records that will be inserted when the + * workflow is done, and those need to be included, e.g., for in-memory + * filters - then override this method (e.g., via an anonymous inner class + * in the specific workflow context class in question), and return appropriate + * pending-insertion records here. + ***************************************************************************/ + public List getRecordsToInsert(String tableName) + { + return Collections.emptyList(); + } + + + + /*************************************************************************** + * if the context is collecting primary keys that will be deleted when the + * workflow is done, and those records need to be excluded, e.g., for in-memory + * filters - then override this method (e.g., via an anonymous inner class + * in the specific workflow context class in question), and return appropriate + * pending-deletion keys here. + ***************************************************************************/ + public Set getPrimaryKeysToDelete(String tableName) + { + return Collections.emptySet(); + } + + + + /*************************************************************************** + * Clear the join records map. Specifically useful if the context is being + * used both during and after workflow execution (e.g. before and after + * records have been stored). + ***************************************************************************/ + public void reset() + { + joinRecords.clear(); + } + + + + /*************************************************************************** + * get the join records for a specific query join against the main record + * of the workflow. + * + *

internally this method does cache those lookups - see {@code reset()} + * to clear that cache.

+ ***************************************************************************/ + public List getJoinRecords(QueryJoin queryJoin) throws QException + { + String joinTableOrAlias = queryJoin.getJoinTableOrItsAlias(); + if(!StringUtils.hasContent(joinTableOrAlias)) + { + throw (new QException("Missing joinTableOrAlias in queryJoin: " + queryJoin)); + } + + List baseRecords; + if(queryJoin.getBaseTableOrAlias().equals(tableName)) + { + baseRecords = List.of(record); + } + else + { + baseRecords = Objects.requireNonNullElseGet(joinRecords.get(queryJoin.getBaseTableOrAlias()), ArrayList::new); + } + + QQueryFilter joinRecordsFilter = getJoinRecordsFilter(queryJoin, baseRecords); + if(joinRecords.get(joinTableOrAlias) == null) + { + List records = joinRecordsFilter == null ? Collections.emptyList() : QueryAction.execute(queryJoin.getJoinTable(), joinRecordsFilter); + joinRecords.put(joinTableOrAlias, records); + } + + return joinRecords.get(joinTableOrAlias); + } + + + + /*************************************************************************** + * For a given queryJoin and a list of 'base records', build a filter that + * can find join records. + * + *

Note that this will potentially be a (a=? AND b=?) OR (a=? AND b=?) + * style query...

+ * + * @return filter to find join records - or - null if there should be no + * join records returned (e.g., empty base record list). + ***************************************************************************/ + protected QQueryFilter getJoinRecordsFilter(QueryJoin queryJoin, List baseRecords) + { + String baseTableName = queryJoin.getBaseTableOrAlias(); + + if(CollectionUtils.nullSafeIsEmpty(baseRecords)) + { + return (null); + } + + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + for(QRecord baseRecord : baseRecords) + { + QQueryFilter subFilter = new QQueryFilter(); + filter.addSubFilter(subFilter); + + for(JoinOn joinOn : queryJoin.getJoinMetaData().getJoinOns()) + { + Serializable mainTableValue; + String joinTableField; + if(queryJoin.getJoinMetaData().getLeftTable().equals(baseTableName)) + { + mainTableValue = baseRecord.getValue(joinOn.getLeftField()); + joinTableField = joinOn.getRightField(); + } + else + { + mainTableValue = baseRecord.getValue(joinOn.getRightField()); + joinTableField = joinOn.getLeftField(); + } + + if(mainTableValue == null) + { + subFilter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.FALSE)); + } + else + { + subFilter.addCriteria(new QFilterCriteria(joinTableField, QCriteriaOperator.EQUALS, mainTableValue)); + } + } + } + + return (filter); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public void setJoinRecords(QueryJoin queryJoin, List joinRecords) + { + ArrayList recordArrayList = CollectionUtils.useOrWrap(joinRecords, new TypeToken<>() {}); + this.joinRecords.put(queryJoin.getJoinTableOrItsAlias(), recordArrayList); + } + + + + /******************************************************************************* + ** Getter for tableName + ** + *******************************************************************************/ + public String getTableName() + { + return tableName; + } + + + + /******************************************************************************* + ** Getter for record + ** + *******************************************************************************/ + public QRecord getRecord() + { + return record; + } +} + diff --git a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java index bcc5602..4a8e962 100644 --- a/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java +++ b/src/main/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/RecordWorkflowTypeTester.java @@ -116,9 +116,10 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface QueryJoin queryJoin = new QueryJoin() .withJoinTable(associatedTableName) + .withBaseTableOrAlias(tableName) .withJoinMetaData(QContext.getQInstance().getJoin(association.get().getJoinName())); - recordWorkflowContext.setJoinRecords(queryJoin, inputRecord, associatedRecords); + recordWorkflowContext.setJoinRecords(queryJoin, associatedRecords); } } } @@ -198,6 +199,12 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface { RecordWorkflowContext context = (RecordWorkflowContext) workflowOutput.getContext(); + ///////////////////////////////////////////////////////////////////////////////////////////////// + // reset the pre-looked up joins in this object (since after the workflow is complete, records // + // will be stored, which wouldn't have been before, so it might have cached old record lists.) // + ///////////////////////////////////////////////////////////////////////////////////////////////// + context.recordWorkflowContextJoinedRecordHelper.get().reset(); + QRecord record = getRecordForTestAssertionFilter(workflowOutput, assertion); if(record == null) { @@ -210,7 +217,7 @@ public class RecordWorkflowTypeTester implements WorkflowTypeTesterInterface // re-use the order filtering logic of InputRecordFilterStep (e.g., building joins) // ////////////////////////////////////////////////////////////////////////////////////// InputRecordFilterStep inputRecordFilterStep = new InputRecordFilterStep(); - List orderWithJoinedRecords = inputRecordFilterStep.buildCrossProduct(record, filter, context); + List orderWithJoinedRecords = InputRecordFilterStep.buildCrossProduct(record, filter, context.recordWorkflowContextJoinedRecordHelper.get()); WorkflowStepOutput workflowStepOutput = inputRecordFilterStep.evaluateCrossProduct(orderWithJoinedRecords, filter); return (Objects.equals(workflowStepOutput.outputData(), true)); diff --git a/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java b/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java index 38fa93e..852706a 100644 --- a/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java +++ b/src/test/java/com/kingsrook/qbits/workflows/BaseTest.java @@ -136,7 +136,7 @@ public class BaseTest /*************************************************************************** ** ***************************************************************************/ - private QInstance defineQInstance() throws QException + protected QInstance defineQInstance() throws QException { ///////////////////////////////////// // basic definition of an instance // diff --git a/src/test/java/com/kingsrook/qbits/workflows/CompanyHierarchyTablesAndJoinsMetaDataProducer.java b/src/test/java/com/kingsrook/qbits/workflows/CompanyHierarchyTablesAndJoinsMetaDataProducer.java new file mode 100644 index 0000000..e2b4d2d --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/workflows/CompanyHierarchyTablesAndJoinsMetaDataProducer.java @@ -0,0 +1,324 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + * MetaData Producer that builds several tables and joins, for a mock HR database. + * + *

ER Diagram (joins only):

+ * + *
+ *   +-------------+
+ *   |   company   |
+ *   +-------------+
+ *         | ↓ 1:N
+ *         |
+ *   N:1 ↑ |
+ *   +-------------+ → M:1 (manager)
+ *   | department  |-------------------+
+ *   +-------------+                   |
+ *         | ↓ 1:N                     |
+ *         |                           |
+ *   N:1 ↑ |                     1:M ↑ v
+ *   +-------------+    1:1    +----------------+
+ *   |  employee   |---------->| employeeDetail |
+ *   +-------------+           +----------------+
+ *   ↓ 1:N |  ^  \
+ *         |  |   \ M:1 (mentor, self-join)
+ *         |  +----+
+ *   N:1 ↑ |
+ *   +-------------+ → M:1     +----------+
+ *   | jobHistory  |---------->| jobTitle |
+ *   +-------------+     1:M ← +----------+
+ * 
+ *******************************************************************************/ +public class CompanyHierarchyTablesAndJoinsMetaDataProducer implements MetaDataProducerInterface +{ + public static final String COMPANY_TABLE = "company"; + public static final String DEPARTMENT_TABLE = "department"; + public static final String EMPLOYEE_TABLE = "employee"; + public static final String EMPLOYEE_DETAIL_TABLE = "employeeDetail"; + public static final String JOB_TITLE_TABLE = "jobTitle"; + public static final String JOB_HISTORY_TABLE = "jobHistory"; + + public static final String COMPANY_JOIN_DEPARTMENT = "companyDepartments"; + public static final String DEPARTMENT_JOIN_COMPANY = "departmentCompany"; + public static final String DEPARTMENT_JOIN_EMPLOYEE = "departmentEmployees"; + public static final String EMPLOYEE_JOIN_DEPARTMENT = "employeeDepartment"; + public static final String EMPLOYEE_JOIN_EMPLOYEE_AS_MENTOR = "employeeMentor"; + public static final String EMPLOYEE_JOIN_EMPLOYEE_DETAIL = "employeeEmployeeDetail"; + public static final String EMPLOYEE_DETAIL_JOIN_EMPLOYEE = "employeeDetailEmployee"; + public static final String EMPLOYEE_JOIN_JOB_HISTORY = "employeeJobHistories"; + public static final String JOB_HISTORY_JOIN_EMPLOYEE = "jobHistoryEmployee"; + public static final String JOB_HISTORY_JOIN_JOB_TITLE = "jobHistoryJobTitle"; + public static final String JOB_TITLE_JOIN_JOB_HISTORY = "jobTitleJobHistories"; + public static final String DEPARTMENT_JOIN_MANAGER = "departmentManager"; + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException + { + MetaDataProducerMultiOutput rs = new MetaDataProducerMultiOutput(); + + //////////// + // Tables // + //////////// + + ///////////////////////////////////// + // Company (root of the hierarchy) // + ///////////////////////////////////// + rs.add(new QTableMetaData() + .withName(COMPANY_TABLE) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("industry", QFieldType.STRING)) + .withField(new QFieldMetaData("foundedDate", QFieldType.DATE)) + .withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_COMPANY, DEPARTMENT_JOIN_EMPLOYEE))) + .withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_DETAIL_TABLE).withJoinPath(List.of(COMPANY_JOIN_DEPARTMENT, DEPARTMENT_JOIN_MANAGER, EMPLOYEE_JOIN_EMPLOYEE_DETAIL)))); + + ////////////////////////////////////////////////////////////////////////////////// + // Department (many departments per company; has a manager FK back to employee) // + ////////////////////////////////////////////////////////////////////////////////// + rs.add(new QTableMetaData() + .withName(DEPARTMENT_TABLE) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("companyId", QFieldType.INTEGER)) // FK -> company + .withField(new QFieldMetaData("managerId", QFieldType.INTEGER)) // FK -> employee (self-ref via join) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("budget", QFieldType.DECIMAL)) + .withExposedJoin(new ExposedJoin().withJoinTable(EMPLOYEE_DETAIL_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_EMPLOYEE, EMPLOYEE_JOIN_EMPLOYEE_DETAIL))) + .withExposedJoin(new ExposedJoin().withJoinTable(JOB_HISTORY_TABLE).withJoinPath(List.of(DEPARTMENT_JOIN_MANAGER, EMPLOYEE_JOIN_JOB_HISTORY))) + ); + + ///////////////////////////////////////////////////////////////////// + // Employee (many employees per department; 1:1 to EmployeeDetail) // + ///////////////////////////////////////////////////////////////////// + rs.add(new QTableMetaData() + .withName(EMPLOYEE_TABLE) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("departmentId", QFieldType.INTEGER)) // FK -> department + .withField(new QFieldMetaData("mentorEmployeeId", QFieldType.INTEGER)) // FK -> employee (self-join) + .withField(new QFieldMetaData("firstName", QFieldType.STRING)) + .withField(new QFieldMetaData("lastName", QFieldType.STRING)) + .withField(new QFieldMetaData("email", QFieldType.STRING)) + .withField(new QFieldMetaData("hireDate", QFieldType.DATE)) + .withField(new QFieldMetaData("isActive", QFieldType.BOOLEAN)) + .withExposedJoin(new ExposedJoin().withJoinTable(COMPANY_TABLE).withJoinPath(List.of(EMPLOYEE_JOIN_DEPARTMENT, DEPARTMENT_JOIN_COMPANY))) + .withExposedJoin(new ExposedJoin().withJoinTable(JOB_TITLE_TABLE).withJoinPath(List.of(EMPLOYEE_JOIN_JOB_HISTORY, JOB_HISTORY_JOIN_JOB_TITLE))) + ); + + ////////////////////////////////////////////////////////////////// + // EmployeeDetail (1:1 with employee - extended/sensitive info) // + ////////////////////////////////////////////////////////////////// + rs.add(new QTableMetaData() + .withName(EMPLOYEE_DETAIL_TABLE) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("employeeId", QFieldType.INTEGER)) // FK -> employee (1:1) + .withField(new QFieldMetaData("phone", QFieldType.STRING)) + .withField(new QFieldMetaData("emergencyContact", QFieldType.STRING)) + .withField(new QFieldMetaData("salary", QFieldType.DECIMAL)) + .withField(new QFieldMetaData("benefitsTier", QFieldType.STRING))); + + //////////////////////////////////////////////////////////////////// + // JobTitle (lookup table - many employees can share a job title) // + //////////////////////////////////////////////////////////////////// + rs.add(new QTableMetaData() + .withName(JOB_TITLE_TABLE) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("grade", QFieldType.STRING)) + .withField(new QFieldMetaData("isExempt", QFieldType.BOOLEAN))); + + /////////////////////////////////////////////////////////////////////////////// + // JobHistory (many job history rows per employee; each row n:1 to jobTitle) // + /////////////////////////////////////////////////////////////////////////////// + rs.add(new QTableMetaData() + .withName(JOB_HISTORY_TABLE) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("employeeId", QFieldType.INTEGER)) // FK -> employee + .withField(new QFieldMetaData("jobTitleId", QFieldType.INTEGER)) // FK -> jobTitle + .withField(new QFieldMetaData("startDate", QFieldType.DATE)) + .withField(new QFieldMetaData("endDate", QFieldType.DATE)) + .withField(new QFieldMetaData("salaryAtTime", QFieldType.DECIMAL))); + + ///////////////////////////////////////// + // set common attributes on all tables // + ///////////////////////////////////////// + rs.getEach(QTableMetaData.class).forEach(t -> + { + t.setPrimaryKeyField("id"); + t.setBackendName(BaseTest.MEMORY_BACKEND_NAME); + }); + + /////////// + // Joins // + /////////// + + ///////////////////////////////////////////////////////////////// + // company 1:n department (company.id = department.companyId) // + ///////////////////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(COMPANY_JOIN_DEPARTMENT) + .withLeftTable(COMPANY_TABLE) + .withRightTable(DEPARTMENT_TABLE) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", "companyId")) + .withOrderBy(new QFilterOrderBy("name"))); + + /////////////////////////////////////////////////////////////////////////////////////////// + // department n:1 company (flip of above - useful when traversing from department side) // + /////////////////////////////////////////////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(DEPARTMENT_JOIN_COMPANY) + .withLeftTable(DEPARTMENT_TABLE) + .withRightTable(COMPANY_TABLE) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("companyId", "id"))); + + ////////////////////////////////////////////////////////////////////// + // department 1:n employee (department.id = employee.departmentId) // + ////////////////////////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(DEPARTMENT_JOIN_EMPLOYEE) + .withLeftTable(DEPARTMENT_TABLE) + .withRightTable(EMPLOYEE_TABLE) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", "departmentId")) + .withOrderBy(new QFilterOrderBy("lastName")) + .withOrderBy(new QFilterOrderBy("firstName"))); + + ////////////////////////////////////////////// + // employee n:1 department (flip of above) // + ////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(EMPLOYEE_JOIN_DEPARTMENT) + .withLeftTable(EMPLOYEE_TABLE) + .withRightTable(DEPARTMENT_TABLE) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("departmentId", "id"))); + + ///////////////////////////////////////////////// + // employee n:1 employee (mentor relationship) // + ///////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(EMPLOYEE_JOIN_EMPLOYEE_AS_MENTOR) + .withLeftTable(EMPLOYEE_TABLE) // the mentee + .withRightTable(EMPLOYEE_TABLE) // the mentor + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("mentorEmployeeId", "id"))); + + //////////////////////////////////////////////////////////////////////////// + // employee 1:1 employeeDetail (employee.id = employeeDetail.employeeId) // + //////////////////////////////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(EMPLOYEE_JOIN_EMPLOYEE_DETAIL) + .withLeftTable(EMPLOYEE_TABLE) + .withRightTable(EMPLOYEE_DETAIL_TABLE) + .withType(JoinType.ONE_TO_ONE) + .withJoinOn(new JoinOn("id", "employeeId"))); + + ////////////////////////////////////////////////// + // employeeDetail 1:1 employee (flip of above) // + ////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(EMPLOYEE_DETAIL_JOIN_EMPLOYEE) + .withLeftTable(EMPLOYEE_DETAIL_TABLE) + .withRightTable(EMPLOYEE_TABLE) + .withType(JoinType.ONE_TO_ONE) + .withJoinOn(new JoinOn("employeeId", "id"))); + + //////////////////////////////////////////////////////////////////// + // employee 1:n jobHistory (employee.id = jobHistory.employeeId) // + //////////////////////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(EMPLOYEE_JOIN_JOB_HISTORY) + .withLeftTable(EMPLOYEE_TABLE) + .withRightTable(JOB_HISTORY_TABLE) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", "employeeId")) + .withOrderBy(new QFilterOrderBy("startDate", false))); // most recent first + + ////////////////////////////////////////////// + // jobHistory n:1 employee (flip of above) // + ////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(JOB_HISTORY_JOIN_EMPLOYEE) + .withLeftTable(JOB_HISTORY_TABLE) + .withRightTable(EMPLOYEE_TABLE) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("employeeId", "id"))); + + //////////////////////////////////////////////////////////////////// + // jobHistory n:1 jobTitle (jobHistory.jobTitleId = jobTitle.id) // + //////////////////////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(JOB_HISTORY_JOIN_JOB_TITLE) + .withLeftTable(JOB_HISTORY_TABLE) + .withRightTable(JOB_TITLE_TABLE) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("jobTitleId", "id"))); + + ////////////////////////////////////////////// + // jobTitle 1:n jobHistory (flip of above) // + ////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(JOB_TITLE_JOIN_JOB_HISTORY) + .withLeftTable(JOB_TITLE_TABLE) + .withRightTable(JOB_HISTORY_TABLE) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", "jobTitleId")) + .withOrderBy(new QFilterOrderBy("startDate", false))); + + ///////////////////////////////////////////////////////////////////////////// + // department n:1 employee (manager) (department.managerId = employee.id) // + ///////////////////////////////////////////////////////////////////////////// + rs.add(new QJoinMetaData() + .withName(DEPARTMENT_JOIN_MANAGER) + .withLeftTable(DEPARTMENT_TABLE) + .withRightTable(EMPLOYEE_TABLE) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("managerId", "id"))); + + return (rs); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStepTest.java b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStepTest.java new file mode 100644 index 0000000..148549c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/workflows/implementations/recordworkflows/InputRecordFilterStepTest.java @@ -0,0 +1,504 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qbits.workflows.implementations.recordworkflows; + + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.workflows.BaseTest; +import com.kingsrook.qbits.workflows.execution.WorkflowStepOutput; +import com.kingsrook.qbits.workflows.model.Workflow; +import com.kingsrook.qbits.workflows.model.WorkflowStep; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.COMPANY_TABLE; +import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.DEPARTMENT_TABLE; +import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.EMPLOYEE_DETAIL_TABLE; +import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.EMPLOYEE_TABLE; +import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.JOB_HISTORY_TABLE; +import static com.kingsrook.qbits.workflows.CompanyHierarchyTablesAndJoinsMetaDataProducer.JOB_TITLE_TABLE; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for InputRecordFilterStep + *******************************************************************************/ +class InputRecordFilterStepTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testWithoutJoins() throws QException + { + ////////////////////////////////////////////////// + // empty filter matches any record (even empty) // + ////////////////////////////////////////////////// + QQueryFilter emptyFilter = new QQueryFilter(); + assertOutput(true, EMPLOYEE_TABLE, emptyFilter, new QRecord()); + assertOutput(true, EMPLOYEE_TABLE, new QQueryFilter(), new QRecord().withValue("id", 1)); + + ///////////////////////////////////////////////// + // simple single criteria - matches or doesn't // + ///////////////////////////////////////////////// + QQueryFilter idEquals1 = new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, 1); + assertOutput(false, EMPLOYEE_TABLE, idEquals1, new QRecord()); + assertOutput(false, EMPLOYEE_TABLE, idEquals1, Map.of("id", 2)); + assertOutput(true, EMPLOYEE_TABLE, idEquals1, Map.of("id", 1)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOneToOneJoin() throws Exception + { + new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecord(new QRecord().withValue("employeeId", 1).withValue("phone", "555-1234"))); + + QQueryFilter phoneContains555 = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".phone", QCriteriaOperator.CONTAINS, "555"); + QQueryFilter phoneContains911 = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".phone", QCriteriaOperator.CONTAINS, "911"); + + /////////////////////////////////////////////////////////////////////// + // employee with detail record with matching phone number is a match // + /////////////////////////////////////////////////////////////////////// + assertOutput(true, EMPLOYEE_TABLE, phoneContains555, Map.of("id", 1)); + + /////////////////////////////////////////////////////////////////////////////// + // employee with detail record with non-matching phone number is NOT a match // + /////////////////////////////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, phoneContains911, Map.of("id", 1)); + + /////////////////////////////////////////////////// + // employee without detail record is NOT a match // + /////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, phoneContains555, Map.of("id", 2)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testManyToOneJoin() throws Exception + { + new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of( + new QRecord().withValue("id", 1).withValue("name", "Engineering"), + new QRecord().withValue("id", 2).withValue("name", "Sales")))); + + QQueryFilter departmentIsEngineering = new QQueryFilter().withCriteria(DEPARTMENT_TABLE + ".name", QCriteriaOperator.EQUALS, "Engineering"); + + ////////////////////////////////////////////////// + // employee with matching department is a match // + ////////////////////////////////////////////////// + assertOutput(true, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 1)); + + ////////////////////////////////////////////////////////// + // employee with non-matching department is NOT a match // + ////////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 2)); + + ////////////////////////////////////////////////// + // employee without a department is not a match // + ////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of()); + assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, MapBuilder.of("departmentId", null)); + assertOutput(false, EMPLOYEE_TABLE, departmentIsEngineering, Map.of("departmentId", 3)); + } + + + + /******************************************************************************* + * employee N:1 department N:1 company + *******************************************************************************/ + @Test + void testManyToOneThenManyToOneJoin() throws Exception + { + new InsertAction().execute(new InsertInput(COMPANY_TABLE).withRecords(List.of( + new QRecord().withValue("id", 1).withValue("name", "ACME"), + new QRecord().withValue("id", 2).withValue("name", "EvilCorp")))); + + new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of( + new QRecord().withValue("id", 11).withValue("companyId", 1).withValue("name", "Engineering"), + new QRecord().withValue("id", 12).withValue("companyId", 1).withValue("name", "Sales"), + new QRecord().withValue("id", 21).withValue("companyId", 2).withValue("name", "Evil"), + new QRecord().withValue("id", 31).withValue("companyId", null).withValue("name", "Company-less")))); + + QQueryFilter companyIsACME = new QQueryFilter().withCriteria(COMPANY_TABLE + ".name", QCriteriaOperator.EQUALS, "ACME"); + + /////////////////////////////////////////////// + // employee with matching company is a match // + /////////////////////////////////////////////// + assertOutput(true, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 11)); + + ////////////////////////////////////////////////////////// + // employee with non-matching department is NOT a match // + ////////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 21)); + + //////////////////////////////////////////////////////////////////// + // employee without a department (thus no company) is not a match // + //////////////////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of()); + assertOutput(false, EMPLOYEE_TABLE, companyIsACME, MapBuilder.of("departmentId", null)); + assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 3)); + + ////////////////////////////////////////////////////////////////////// + // employee without a department that has no company is not a match // + ////////////////////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, companyIsACME, Map.of("departmentId", 31)); + } + + + + /******************************************************************************* + ** department 1:N employee 1:1 employeeDetail + *******************************************************************************/ + @Test + void testOneToManyThenOneToOneJoin() throws Exception + { + ////////////////////////////////////////////////////////////////////////////////////// + // this test queries for Departments, but on fields from EmployeeDetail // + // The relationship is Department, one-to-many Employee, one-to-one EmployeeDetail. // + ////////////////////////////////////////////////////////////////////////////////////// + new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of( + new QRecord().withValue("id", 1).withValue("departmentId", 11), + new QRecord().withValue("id", 2).withValue("departmentId", 21), + new QRecord().withValue("id", 3).withValue("departmentId", 31)))); + + new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecords(List.of( + new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("salary", new BigDecimal("105000")), + new QRecord().withValue("id", 102).withValue("employeeId", 2).withValue("salary", new BigDecimal("95000"))))); + + QQueryFilter hasEmployeeOver100K = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".salary", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000")); + + /////////////////////////////////////////////////////////// + // department with a matching employee detail is a match // + /////////////////////////////////////////////////////////// + assertOutput(true, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 11)); + + ///////////////////////////////////////////////////////////////////////////////////// + // department with an employee, who has a detail, but doesn't match is NOT a match // + ///////////////////////////////////////////////////////////////////////////////////// + assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 21)); + + ///////////////////////////////////////////////////////////////////// + // department with no employee, but no employee detail is no match // + ///////////////////////////////////////////////////////////////////// + assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 31)); + + ///////////////////////////////////////////// + // department with no employee is no match // + ///////////////////////////////////////////// + assertOutput(false, DEPARTMENT_TABLE, hasEmployeeOver100K, Map.of("id", 41)); + } + + + + /******************************************************************************* + ** employee 1:N jobHistory + *******************************************************************************/ + @Test + void testOneToManyJoin() throws Exception + { + new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of( + new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("120000")), + new QRecord().withValue("id", 102).withValue("employeeId", 2).withValue("salaryAtTime", new BigDecimal("90000"))))); + + QQueryFilter hasJobHistoryOver100K = new QQueryFilter().withCriteria(JOB_HISTORY_TABLE + ".salaryAtTime", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000")); + + ////////////////////////////////////////////////// + // employee with a matching job history matches // + ////////////////////////////////////////////////// + assertOutput(true, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 1)); + + /////////////////////////////////////////////////// + // employee with only non-matching rows does not // + /////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 2)); + + //////////////////////////////////// + // employee with no rows does not // + //////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, hasJobHistoryOver100K, Map.of("id", 3)); + } + + + + /******************************************************************************* + ** employee 1:N jobHistory N:1 jobTitle + *******************************************************************************/ + @Test + void testOneToManyThenManyToOneJoin() throws Exception + { + new InsertAction().execute(new InsertInput(JOB_TITLE_TABLE).withRecords(List.of( + new QRecord().withValue("id", 11).withValue("name", "Engineer"), + new QRecord().withValue("id", 12).withValue("name", "Intern")))); + + new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of( + new QRecord().withValue("id", 101).withValue("employeeId", 1).withValue("jobTitleId", 11), + new QRecord().withValue("id", 102).withValue("employeeId", 1).withValue("jobTitleId", 12), + new QRecord().withValue("id", 201).withValue("employeeId", 2).withValue("jobTitleId", 12), + new QRecord().withValue("id", 301).withValue("employeeId", 3).withValue("jobTitleId", null)))); + + QQueryFilter hasHadEngineerTitle = new QQueryFilter().withCriteria(JOB_TITLE_TABLE + ".name", QCriteriaOperator.EQUALS, "Engineer"); + QQueryFilter hasHadInternTitle = new QQueryFilter().withCriteria(JOB_TITLE_TABLE + ".name", QCriteriaOperator.EQUALS, "Intern"); + + //////////////////////////////////////////////////////////// + // employee with multiple matching titles through history // + //////////////////////////////////////////////////////////// + assertOutput(true, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 1)); + assertOutput(true, EMPLOYEE_TABLE, hasHadInternTitle, Map.of("id", 1)); + + ///////////////////////////////////////////////////////// + // employee with single matching title through history // + ///////////////////////////////////////////////////////// + assertOutput(true, EMPLOYEE_TABLE, hasHadInternTitle, Map.of("id", 1)); + + ////////////////////////////////////////////////////// + // employee with non-matching title through history // + ////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 2)); + + //////////////////////////////////////////////////////// + // employee with job history but missing title target // + //////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 3)); + + ////////////////////////////////// + // employee with no job history // + ////////////////////////////////// + assertOutput(false, EMPLOYEE_TABLE, hasHadEngineerTitle, Map.of("id", 4)); + } + + + + /******************************************************************************* + ** company 1:N department 1:N employee + *******************************************************************************/ + @Test + void testOneToManyThenOneToManyJoin() throws Exception + { + new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of( + new QRecord().withValue("id", 11).withValue("companyId", 1).withValue("name", "Engineering"), + new QRecord().withValue("id", 12).withValue("companyId", 1).withValue("name", "Support"), + new QRecord().withValue("id", 21).withValue("companyId", 2).withValue("name", "Support"), + new QRecord().withValue("id", 31).withValue("companyId", 3).withValue("name", "Safety"), + new QRecord().withValue("id", 41).withValue("companyId", 4).withValue("name", "Empty Department")))); + + new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of( + new QRecord().withValue("departmentId", 11).withValue("lastName", "Smith").withValue("firstName", "Ozzie"), + new QRecord().withValue("departmentId", 12).withValue("lastName", "Smith").withValue("firstName", "Will"), + new QRecord().withValue("departmentId", 11).withValue("lastName", "Jones"), + new QRecord().withValue("departmentId", 21).withValue("lastName", "Jones"), + new QRecord().withValue("departmentId", 31).withValue("lastName", "Simpson"), + new QRecord().withValue("departmentId", 21).withValue("lastName", "Taylor")))); + + QQueryFilter hasEmployeeNamedSmith = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Smith"); + QQueryFilter hasEmployeeNamedSimpson = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Simpson"); + + ///////////////////////////////////////////////// + // company with at least one matching employee // + ///////////////////////////////////////////////// + assertOutput(true, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 1)); + assertOutput(true, COMPANY_TABLE, hasEmployeeNamedSimpson, Map.of("id", 3)); + + ////////////////////////////////////////////// + // company with employees but none matching // + ////////////////////////////////////////////// + assertOutput(false, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 2)); + + /////////////////////////////////////////////// + // company with departments but no employees // + /////////////////////////////////////////////// + assertOutput(false, COMPANY_TABLE, hasEmployeeNamedSmith, Map.of("id", 4)); + } + + + + /******************************************************************************* + ** department N:1 manager(employee) 1:N jobHistory + *******************************************************************************/ + @Test + void testManyToOneThenOneToManyJoin() throws Exception + { + new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of( + new QRecord().withValue("id", 1).withValue("lastName", "Smith"), + new QRecord().withValue("id", 2).withValue("lastName", "Jones"), + new QRecord().withValue("id", 3).withValue("lastName", "Milton") + ))); + + new InsertAction().execute(new InsertInput(JOB_HISTORY_TABLE).withRecords(List.of( + new QRecord().withValue("id", 301).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("130000")), + new QRecord().withValue("id", 302).withValue("employeeId", 1).withValue("salaryAtTime", new BigDecimal("120000")), + new QRecord().withValue("id", 303).withValue("employeeId", 2).withValue("salaryAtTime", new BigDecimal("80000"))))); + + QQueryFilter managerHasHistoryOver100K = new QQueryFilter().withCriteria(JOB_HISTORY_TABLE + ".salaryAtTime", QCriteriaOperator.GREATER_THAN, new BigDecimal("100000")); + + ////////////////////////////////////////////////////////// + // department whose manager has matching history row(s) // + ////////////////////////////////////////////////////////// + assertOutput(true, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 1)); + + ///////////////////////////////////////////////////////// + // department whose manager has only non-matching rows // + ///////////////////////////////////////////////////////// + assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 2)); + + //////////////////////////////////////////////////////////// + // department with null or unknown manager does not match // + //////////////////////////////////////////////////////////// + assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of()); + assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 99)); + + ////////////////////////////////////////////////// + // manager who has no job history doesn't match // + ////////////////////////////////////////////////// + assertOutput(false, DEPARTMENT_TABLE, managerHasHistoryOver100K, Map.of("managerId", 3)); + } + + + + /******************************************************************************* + ** employeeDetail 1:1 employee (reverse direction of employee->employeeDetail) + *******************************************************************************/ + @Test + void testOneToOneReverseJoin() throws Exception + { + new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of( + new QRecord().withValue("id", 100).withValue("lastName", "Smith"), + new QRecord().withValue("id", 101).withValue("lastName", "Jones")))); + + QQueryFilter employeeLastNameIsSmith = new QQueryFilter().withCriteria(EMPLOYEE_TABLE + ".lastName", QCriteriaOperator.EQUALS, "Smith"); + + ///////////////////////////////////////////////////// + // detail row linked to matching employee is match // + ///////////////////////////////////////////////////// + assertOutput(true, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 100)); + + ///////////////////////////////////////////////////////// + // detail row linked to non-matching employee no match // + ///////////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 101)); + + //////////////////////////////////////////////////// + // detail row with null/unknown employee no match // + //////////////////////////////////////////////////// + assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, MapBuilder.of("employeeId", null)); + assertOutput(false, EMPLOYEE_DETAIL_TABLE, employeeLastNameIsSmith, Map.of("employeeId", 999)); + } + + + + /******************************************************************************* + ** company 1:N department N:1 manager(employee) 1:1 employeeDetail + *******************************************************************************/ + @Test + void testOneToManyThenManyToOneThenOneToOneJoin() throws Exception + { + new InsertAction().execute(new InsertInput(EMPLOYEE_TABLE).withRecords(List.of( + new QRecord().withValue("id", 11).withValue("lastName", "Smith"), + new QRecord().withValue("id", 21).withValue("lastName", "Jones"), + new QRecord().withValue("id", 31).withValue("lastName", "Milton") + ))); + + new InsertAction().execute(new InsertInput(DEPARTMENT_TABLE).withRecords(List.of( + new QRecord().withValue("id", 401).withValue("companyId", 1).withValue("managerId", 11).withValue("name", "Engineering"), + new QRecord().withValue("id", 402).withValue("companyId", 2).withValue("managerId", 21).withValue("name", "Support"), + new QRecord().withValue("id", 403).withValue("companyId", 3).withValue("managerId", 31).withValue("name", "Office Space"), + new QRecord().withValue("id", 404).withValue("companyId", 4).withValue("managerId", null).withValue("name", "No Manager")))); + + new InsertAction().execute(new InsertInput(EMPLOYEE_DETAIL_TABLE).withRecords(List.of( + new QRecord().withValue("id", 501).withValue("employeeId", 11).withValue("benefitsTier", "PLATINUM"), + new QRecord().withValue("id", 502).withValue("employeeId", 21).withValue("benefitsTier", "BASIC")))); + + QQueryFilter hasManagerWithPlatinumTier = new QQueryFilter().withCriteria(EMPLOYEE_DETAIL_TABLE + ".benefitsTier", QCriteriaOperator.EQUALS, "PLATINUM"); + + /////////////////////////////////////////////////////////////// + // company with department whose manager has matching detail // + /////////////////////////////////////////////////////////////// + assertOutput(true, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 1)); + + /////////////////////////////////////////////////////////////// + // company with department whose manager detail is non-match // + /////////////////////////////////////////////////////////////// + assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 2)); + + /////////////////////////////////////////////////// + // company with department whose manager is null // + /////////////////////////////////////////////////// + assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 4)); + + ////////////////////////////////////////////////////////// + // company with department whose manager has no history // + ////////////////////////////////////////////////////////// + assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 3)); + + ///////////////////////////////////////////// + // company with no departments is no match // + ///////////////////////////////////////////// + assertOutput(false, COMPANY_TABLE, hasManagerWithPlatinumTier, Map.of("id", 5)); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, Map recordValues) throws QException + { + QRecord record = new QRecord(); + record.setValues(recordValues); + assertOutput(expectedOutput, tableName, filter, record); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + void assertOutput(boolean expectedOutput, String tableName, QQueryFilter filter, QRecord record) throws QException + { + WorkflowStep step = new WorkflowStep(); + Map inputValues = Map.of("queryFilterJson", JsonUtils.toJson(filter)); + + RecordWorkflowContext context = new RecordWorkflowContext(); + context.setWorkflow(new Workflow().withTableName(tableName)); + context.record.set(record); + record.setTableName(tableName); + + WorkflowStepOutput stepOutput = new InputRecordFilterStep().execute(step, inputValues, context); + assertEquals(expectedOutput, stepOutput.outputData()); + } +}