From 73e826f81db747801876aa13ec570df23cf4896e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 7 Sep 2023 12:23:12 -0500 Subject: [PATCH] Join Enhancements: - Moving responsibility for adding security clauses out of AbstractRDBMSAction, into JoinsContext - Adding QueryJoin securityClauses (helps outer-join security filtering work as expected) - Add security clauses for all joined tables - Improved inferring of joinMetaData, especially from ExposedJoins - Fix processes use of selectDistinct when ordering by a field from a joinTable (by doing the Distinct in the record pipe) --- .../DistinctFilteringRecordPipe.java | 146 +++ .../core/actions/reporting/ExportAction.java | 25 +- .../core/actions/reporting/RecordPipe.java | 8 +- .../actions/tables/query/JoinsContext.java | 595 +++++++++-- .../actions/tables/query/QQueryFilter.java | 2 +- .../model/actions/tables/query/QueryJoin.java | 73 +- .../memory/MemoryRecordStore.java | 11 +- .../AbstractExtractStep.java | 13 + .../ExtractViaQueryStep.java | 80 ++ .../StreamedETLExecuteStep.java | 19 +- .../StreamedETLPreviewStep.java | 22 +- .../StreamedETLValidateStep.java | 22 +- .../rdbms/actions/AbstractRDBMSAction.java | 262 ++--- .../rdbms/actions/RDBMSAggregateAction.java | 16 +- .../rdbms/actions/RDBMSCountAction.java | 11 +- .../rdbms/actions/RDBMSDeleteAction.java | 2 +- .../rdbms/actions/RDBMSQueryAction.java | 9 +- .../ExportActionWithinRDBMSTest.java | 85 ++ .../qqq/backend/module/rdbms/TestUtils.java | 1 + .../rdbms/actions/RDBMSInsertActionTest.java | 4 +- .../actions/RDBMSQueryActionJoinsTest.java | 987 ++++++++++++++++++ .../rdbms/actions/RDBMSQueryActionTest.java | 786 -------------- 22 files changed, 2055 insertions(+), 1124 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/DistinctFilteringRecordPipe.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportActionWithinRDBMSTest.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/DistinctFilteringRecordPipe.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/DistinctFilteringRecordPipe.java new file mode 100644 index 00000000..ad2aa3fd --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/DistinctFilteringRecordPipe.java @@ -0,0 +1,146 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.actions.reporting; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; + + +/******************************************************************************* + ** Subclass of record pipe that ony allows through distinct records, based on + ** the set of fields specified in the constructor as a uniqueKey. + *******************************************************************************/ +public class DistinctFilteringRecordPipe extends RecordPipe +{ + private UniqueKey uniqueKey; + private Set seenValues = new HashSet<>(); + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public DistinctFilteringRecordPipe(UniqueKey uniqueKey) + { + this.uniqueKey = uniqueKey; + } + + + + /******************************************************************************* + ** Constructor that accepts pipe's overrideCapacity (allowed to be null) + ** + *******************************************************************************/ + public DistinctFilteringRecordPipe(UniqueKey uniqueKey, Integer overrideCapacity) + { + super(overrideCapacity); + this.uniqueKey = uniqueKey; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void addRecords(List records) throws QException + { + List recordsToAdd = new ArrayList<>(); + for(QRecord record : records) + { + if(!seenBefore(record)) + { + recordsToAdd.add(record); + } + } + + if(recordsToAdd.isEmpty()) + { + return; + } + + super.addRecords(recordsToAdd); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void addRecord(QRecord record) throws QException + { + if(seenBefore(record)) + { + return; + } + + super.addRecord(record); + } + + + + /******************************************************************************* + ** return true if we've seen this record before (based on the unique key) - + ** also - update the set of seen values! + *******************************************************************************/ + private boolean seenBefore(QRecord record) + { + Serializable ukValues = extractUKValues(record); + if(seenValues.contains(ukValues)) + { + return true; + } + seenValues.add(ukValues); + return false; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private Serializable extractUKValues(QRecord record) + { + if(uniqueKey.getFieldNames().size() == 1) + { + return (record.getValue(uniqueKey.getFieldNames().get(0))); + } + else + { + ArrayList rs = new ArrayList<>(); + for(String fieldName : uniqueKey.getFieldNames()) + { + rs.add(record.getValue(fieldName)); + } + return (rs); + } + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportAction.java index 6bd4b83d..51241382 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportAction.java @@ -189,6 +189,9 @@ public class ExportAction Set addedJoinNames = new HashSet<>(); if(CollectionUtils.nullSafeHasContents(exportInput.getFieldNames())) { + ///////////////////////////////////////////////////////////////////////////////////////////// + // make sure that any tables being selected from are included as (LEFT) joins in the query // + ///////////////////////////////////////////////////////////////////////////////////////////// for(String fieldName : exportInput.getFieldNames()) { if(fieldName.contains(".")) @@ -197,27 +200,7 @@ public class ExportAction String joinTableName = parts[0]; if(!addedJoinNames.contains(joinTableName)) { - QueryJoin queryJoin = new QueryJoin(joinTableName).withType(QueryJoin.Type.LEFT).withSelect(true); - queryJoins.add(queryJoin); - - ///////////////////////////////////////////////////////////////////////////////////////////// - // in at least some cases, we need to let the queryJoin know what join-meta-data to use... // - // This code basically mirrors what QFMD is doing right now, so it's better - // - // but shouldn't all of this just be in JoinsContext? it does some of this... // - ///////////////////////////////////////////////////////////////////////////////////////////// - QTableMetaData table = exportInput.getTable(); - Optional exposedJoinOptional = CollectionUtils.nonNullList(table.getExposedJoins()).stream().filter(ej -> ej.getJoinTable().equals(joinTableName)).findFirst(); - if(exposedJoinOptional.isEmpty()) - { - throw (new QException("Could not find exposed join between base table " + table.getName() + " and requested join table " + joinTableName)); - } - ExposedJoin exposedJoin = exposedJoinOptional.get(); - - if(exposedJoin.getJoinPath().size() == 1) - { - queryJoin.setJoinMetaData(QContext.getQInstance().getJoin(exposedJoin.getJoinPath().get(exposedJoin.getJoinPath().size() - 1))); - } - + queryJoins.add(new QueryJoin(joinTableName).withType(QueryJoin.Type.LEFT).withSelect(true)); addedJoinNames.add(joinTableName); } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java index dff2c4de..9625d4e8 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/RecordPipe.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.actions.reporting; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.TimeUnit; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -43,8 +44,9 @@ public class RecordPipe private static final long BLOCKING_SLEEP_MILLIS = 100; private static final long MAX_SLEEP_LOOP_MILLIS = 300_000; // 5 minutes + private static final int DEFAULT_CAPACITY = 1_000; - private ArrayBlockingQueue queue = new ArrayBlockingQueue<>(1_000); + private ArrayBlockingQueue queue = new ArrayBlockingQueue<>(DEFAULT_CAPACITY); private boolean isTerminated = false; @@ -69,10 +71,12 @@ public class RecordPipe /******************************************************************************* ** Construct a record pipe, with an alternative capacity for the internal queue. + ** + ** overrideCapacity is allowed to be null - in which case, DEFAULT_CAPACITY is used. *******************************************************************************/ public RecordPipe(Integer overrideCapacity) { - queue = new ArrayBlockingQueue<>(overrideCapacity); + queue = new ArrayBlockingQueue<>(Objects.requireNonNullElse(overrideCapacity, DEFAULT_CAPACITY)); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/JoinsContext.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/JoinsContext.java index 8fa6f5ec..70aa2ccb 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/JoinsContext.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/JoinsContext.java @@ -22,6 +22,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query; +import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -37,12 +38,16 @@ import com.kingsrook.qqq.backend.core.logging.LogPair; import com.kingsrook.qqq.backend.core.logging.QLogger; 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.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLockFilters; import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.collections.MutableList; import org.apache.logging.log4j.Level; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -60,11 +65,17 @@ public class JoinsContext private final String mainTableName; private final List queryJoins; + private final QQueryFilter securityFilter; + //////////////////////////////////////////////////////////////// // note - will have entries for all tables, not just aliases. // //////////////////////////////////////////////////////////////// private final Map aliasToTableNameMap = new HashMap<>(); - private Level logLevel = Level.OFF; + + ///////////////////////////////////////////////////////////////////////////// + // we will get a TON of more output if this gets turned up, so be cautious // + ///////////////////////////////////////////////////////////////////////////// + private Level logLevel = Level.OFF; @@ -74,54 +85,182 @@ public class JoinsContext *******************************************************************************/ public JoinsContext(QInstance instance, String tableName, List queryJoins, QQueryFilter filter) throws QException { - log("--- START ----------------------------------------------------------------------", logPair("mainTable", tableName)); this.instance = instance; this.mainTableName = tableName; this.queryJoins = new MutableList<>(queryJoins); + this.securityFilter = new QQueryFilter(); + + // log("--- START ----------------------------------------------------------------------", logPair("mainTable", tableName)); + dumpDebug(true, false); for(QueryJoin queryJoin : this.queryJoins) { - log("Processing input query join", logPair("joinTable", queryJoin.getJoinTable()), logPair("alias", queryJoin.getAlias()), logPair("baseTableOrAlias", queryJoin.getBaseTableOrAlias()), logPair("joinMetaDataName", () -> queryJoin.getJoinMetaData().getName())); processQueryJoin(queryJoin); } + ///////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure that all tables specified in filter columns are being brought into the query as joins // + ///////////////////////////////////////////////////////////////////////////////////////////////////// + ensureFilterIsRepresented(filter); + + /////////////////////////////////////////////////////////////////////////////////////// + // ensure that any record locks on the main table, which require a join, are present // + /////////////////////////////////////////////////////////////////////////////////////// + for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(instance.getTable(tableName).getRecordSecurityLocks()))) + { + ensureRecordSecurityLockIsRepresented(tableName, tableName, recordSecurityLock, null); + } + + /////////////////////////////////////////////////////////////////////////////////// + // make sure that all joins in the query have meta data specified // + // e.g., a user-added join may just specify the join-table // + // or a join implicitly added from a filter may also not have its join meta data // + /////////////////////////////////////////////////////////////////////////////////// + fillInMissingJoinMetaData(); + /////////////////////////////////////////////////////////////// // ensure any joins that contribute a recordLock are present // /////////////////////////////////////////////////////////////// - for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(instance.getTable(tableName).getRecordSecurityLocks()))) - { - ensureRecordSecurityLockIsRepresented(instance, tableName, recordSecurityLock); - } + ensureAllJoinRecordSecurityLocksAreRepresented(instance); - ensureFilterIsRepresented(filter); - - addJoinsFromExposedJoinPaths(); - - /* todo!! - for(QueryJoin queryJoin : queryJoins) - { - QTableMetaData joinTable = instance.getTable(queryJoin.getJoinTable()); - for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks())) - { - // addCriteriaForRecordSecurityLock(instance, session, joinTable, securityCriteria, recordSecurityLock, joinsContext, queryJoin.getJoinTableOrItsAlias()); - } - } - */ + //////////////////////////////////////////////////////////////////////////////////// + // if there were any security filters built, then put those into the input filter // + //////////////////////////////////////////////////////////////////////////////////// + addSecurityFiltersToInputFilter(filter); log("Constructed JoinsContext", logPair("mainTableName", this.mainTableName), logPair("queryJoins", this.queryJoins.stream().map(qj -> qj.getJoinTable()).collect(Collectors.joining(",")))); - log("--- END ------------------------------------------------------------------------"); + log("", logPair("securityFilter", securityFilter)); + log("", logPair("fullFilter", filter)); + dumpDebug(false, true); + // log("--- END ------------------------------------------------------------------------"); } /******************************************************************************* - ** + ** Update the input filter with any security filters that were built. *******************************************************************************/ - private void ensureRecordSecurityLockIsRepresented(QInstance instance, String tableName, RecordSecurityLock recordSecurityLock) throws QException + private void addSecurityFiltersToInputFilter(QQueryFilter filter) { + //////////////////////////////////////////////////////////////////////////////////// + // if there's no security filter criteria (including sub-filters), return w/ noop // + //////////////////////////////////////////////////////////////////////////////////// + if(CollectionUtils.nullSafeIsEmpty(securityFilter.getSubFilters())) + { + return; + } + + /////////////////////////////////////////////////////////////////////// + // if the input filter is an OR we need to replace it with a new AND // + /////////////////////////////////////////////////////////////////////// + if(filter.getBooleanOperator().equals(QQueryFilter.BooleanOperator.OR)) + { + List originalCriteria = filter.getCriteria(); + List originalSubFilters = filter.getSubFilters(); + + QQueryFilter replacementFilter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + replacementFilter.setCriteria(originalCriteria); + replacementFilter.setSubFilters(originalSubFilters); + + filter.setCriteria(new ArrayList<>()); + filter.setSubFilters(new ArrayList<>()); + filter.setBooleanOperator(QQueryFilter.BooleanOperator.AND); + filter.addSubFilter(replacementFilter); + } + + for(QQueryFilter subFilter : securityFilter.getSubFilters()) + { + filter.addSubFilter(subFilter); + } + } + + + + /******************************************************************************* + ** In case we've added any joins to the query that have security locks which + ** weren't previously added to the query, add them now. basically, this is + ** calling ensureRecordSecurityLockIsRepresented for each queryJoin. + *******************************************************************************/ + private void ensureAllJoinRecordSecurityLocksAreRepresented(QInstance instance) throws QException + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // avoid concurrent modification exceptions by doing a double-loop and breaking the inner any time anything gets added // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + Set processedQueryJoins = new HashSet<>(); + boolean addedAnyThisIteration = true; + while(addedAnyThisIteration) + { + addedAnyThisIteration = false; + + for(QueryJoin queryJoin : this.queryJoins) + { + boolean addedAnyForThisJoin = false; + + ///////////////////////////////////////////////// + // avoid double-processing the same query join // + ///////////////////////////////////////////////// + if(processedQueryJoins.contains(queryJoin)) + { + continue; + } + processedQueryJoins.add(queryJoin); + + ////////////////////////////////////////////////////////////////////////////////////////// + // process all locks on this join's join-table. keep track if any new joins were added // + ////////////////////////////////////////////////////////////////////////////////////////// + QTableMetaData joinTable = instance.getTable(queryJoin.getJoinTable()); + for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks())) + { + List addedQueryJoins = ensureRecordSecurityLockIsRepresented(joinTable.getName(), queryJoin.getJoinTableOrItsAlias(), recordSecurityLock, queryJoin); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if any joins were added by this call, add them to the set of processed ones, so they don't get re-processed. // + // also mark the flag that any were added for this join, to manage the double-looping // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(CollectionUtils.nullSafeHasContents(addedQueryJoins)) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // make all new joins added in that method be of the same type (inner/left/etc) as the query join they are connected to // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + for(QueryJoin addedQueryJoin : addedQueryJoins) + { + addedQueryJoin.setType(queryJoin.getType()); + } + + processedQueryJoins.addAll(addedQueryJoins); + addedAnyForThisJoin = true; + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // if any new joins were added, we need to break the inner-loop, and continue the outer loop // + // e.g., to process the next query join (but we can't just go back to the foreach queryJoin, // + // because it would fail with concurrent modification) // + /////////////////////////////////////////////////////////////////////////////////////////////// + if(addedAnyForThisJoin) + { + addedAnyThisIteration = true; + break; + } + } + } + } + + + + /******************************************************************************* + ** For a given recordSecurityLock on a given table (with a possible alias), + ** make sure that if any joins are needed to get to the lock, that they are in the query. + ** + ** returns the list of query joins that were added, if any were added + *******************************************************************************/ + private List ensureRecordSecurityLockIsRepresented(String tableName, String tableNameOrAlias, RecordSecurityLock recordSecurityLock, QueryJoin sourceQueryJoin) throws QException + { + List addedQueryJoins = new ArrayList<>(); + /////////////////////////////////////////////////////////////////////////////////////////////////// - // ok - so - the join name chain is going to be like this: // - // for a table: orderLineItemExtrinsic (that's 2 away from order, where the security field is): // + // A join name chain is going to look like this: // + // for a table: orderLineItemExtrinsic (that's 2 away from order, where its security field is): // // - securityFieldName = order.clientId // // - joinNameChain = orderJoinOrderLineItem, orderLineItemJoinOrderLineItemExtrinsic // // so - to navigate from the table to the security field, we need to reverse the joinNameChain, // @@ -129,30 +268,30 @@ public class JoinsContext /////////////////////////////////////////////////////////////////////////////////////////////////// ArrayList joinNameChain = new ArrayList<>(CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())); Collections.reverse(joinNameChain); - log("Evaluating recordSecurityLock", logPair("recordSecurityLock", recordSecurityLock.getFieldName()), logPair("joinNameChain", joinNameChain)); + log("Evaluating recordSecurityLock. Join name chain is of length: " + joinNameChain.size(), logPair("tableNameOrAlias", tableNameOrAlias), logPair("recordSecurityLock", recordSecurityLock.getFieldName()), logPair("joinNameChain", joinNameChain)); - QTableMetaData tmpTable = instance.getTable(mainTableName); + QTableMetaData tmpTable = instance.getTable(tableName); + String securityFieldTableAlias = tableNameOrAlias; + String baseTableOrAlias = tableNameOrAlias; + + boolean chainIsInner = true; + if(sourceQueryJoin != null && QueryJoin.Type.isOuter(sourceQueryJoin.getType())) + { + chainIsInner = false; + } for(String joinName : joinNameChain) { - /////////////////////////////////////////////////////////////////////////////////////////////////////// - // check the joins currently in the query - if any are for this table, then we don't need to add one // - /////////////////////////////////////////////////////////////////////////////////////////////////////// - List matchingJoins = this.queryJoins.stream().filter(queryJoin -> + ////////////////////////////////////////////////////////////////////////////////////////////////// + // check the joins currently in the query - if any are THIS join, then we don't need to add one // + ////////////////////////////////////////////////////////////////////////////////////////////////// + List matchingQueryJoins = this.queryJoins.stream().filter(queryJoin -> { - QJoinMetaData joinMetaData = null; - if(queryJoin.getJoinMetaData() != null) - { - joinMetaData = queryJoin.getJoinMetaData(); - } - else - { - joinMetaData = findJoinMetaData(instance, tableName, queryJoin.getJoinTable()); - } + QJoinMetaData joinMetaData = queryJoin.getJoinMetaData(); return (joinMetaData != null && Objects.equals(joinMetaData.getName(), joinName)); }).toList(); - if(CollectionUtils.nullSafeHasContents(matchingJoins)) + if(CollectionUtils.nullSafeHasContents(matchingQueryJoins)) { ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// // note - if a user added a join as an outer type, we need to change it to be inner, for the security purpose. // @@ -160,11 +299,40 @@ public class JoinsContext ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// log("- skipping join already in the query", logPair("joinName", joinName)); - if(matchingJoins.get(0).getType().equals(QueryJoin.Type.LEFT) || matchingJoins.get(0).getType().equals(QueryJoin.Type.RIGHT)) + QueryJoin matchedQueryJoin = matchingQueryJoins.get(0); + + if(matchedQueryJoin.getType().equals(QueryJoin.Type.LEFT) || matchedQueryJoin.getType().equals(QueryJoin.Type.RIGHT)) + { + chainIsInner = false; + } + + /* ?? todo ?? + if(matchedQueryJoin.getType().equals(QueryJoin.Type.LEFT) || matchedQueryJoin.getType().equals(QueryJoin.Type.RIGHT)) { log("- - although... it was here as an outer - so switching it to INNER", logPair("joinName", joinName)); - matchingJoins.get(0).setType(QueryJoin.Type.INNER); + matchedQueryJoin.setType(QueryJoin.Type.INNER); } + */ + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // as we're walking from tmpTable to the table which ultimately has the security key field, // + // if the queryJoin we just found is joining out to tmpTable, then we need to advance tmpTable back // + // to the queryJoin's base table - else, tmpTable advances to the matched queryJoin's joinTable // + ////////////////////////////////////////////////////////////////////////////////////////////////////// + if(tmpTable.getName().equals(matchedQueryJoin.getJoinTable())) + { + securityFieldTableAlias = Objects.requireNonNullElse(matchedQueryJoin.getBaseTableOrAlias(), mainTableName); + } + else + { + securityFieldTableAlias = matchedQueryJoin.getJoinTableOrItsAlias(); + } + tmpTable = instance.getTable(securityFieldTableAlias); + + //////////////////////////////////////////////////////////////////////////////////////// + // set the baseTableOrAlias for the next iteration to be this join's joinTableOrAlias // + //////////////////////////////////////////////////////////////////////////////////////// + baseTableOrAlias = securityFieldTableAlias; continue; } @@ -172,20 +340,193 @@ public class JoinsContext QJoinMetaData join = instance.getJoin(joinName); if(join.getLeftTable().equals(tmpTable.getName())) { - QueryJoin queryJoin = new ImplicitQueryJoinForSecurityLock().withJoinMetaData(join).withType(QueryJoin.Type.INNER); - this.addQueryJoin(queryJoin, "forRecordSecurityLock (non-flipped)"); + securityFieldTableAlias = join.getRightTable() + "_forSecurityJoin_" + join.getName(); + QueryJoin queryJoin = new ImplicitQueryJoinForSecurityLock() + .withJoinMetaData(join) + .withType(chainIsInner ? QueryJoin.Type.INNER : QueryJoin.Type.LEFT) + .withBaseTableOrAlias(baseTableOrAlias) + .withAlias(securityFieldTableAlias); + + addQueryJoin(queryJoin, "forRecordSecurityLock (non-flipped)", "- "); + addedQueryJoins.add(queryJoin); tmpTable = instance.getTable(join.getRightTable()); } else if(join.getRightTable().equals(tmpTable.getName())) { - QueryJoin queryJoin = new ImplicitQueryJoinForSecurityLock().withJoinMetaData(join.flip()).withType(QueryJoin.Type.INNER); - this.addQueryJoin(queryJoin, "forRecordSecurityLock (flipped)"); + securityFieldTableAlias = join.getLeftTable() + "_forSecurityJoin_" + join.getName(); + QueryJoin queryJoin = new ImplicitQueryJoinForSecurityLock() + .withJoinMetaData(join.flip()) + .withType(chainIsInner ? QueryJoin.Type.INNER : QueryJoin.Type.LEFT) + .withBaseTableOrAlias(baseTableOrAlias) + .withAlias(securityFieldTableAlias); + + addQueryJoin(queryJoin, "forRecordSecurityLock (flipped)", "- "); + addedQueryJoins.add(queryJoin); tmpTable = instance.getTable(join.getLeftTable()); } else { + dumpDebug(false, true); throw (new QException("Error adding security lock joins to query - table name [" + tmpTable.getName() + "] not found in join [" + joinName + "]")); } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for the next iteration of the loop, set the next join's baseTableOrAlias to be the alias we just created // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + baseTableOrAlias = securityFieldTableAlias; + } + + //////////////////////////////////////////////////////////////////////////////////// + // now that we know the joins/tables are in the query, add to the security filter // + //////////////////////////////////////////////////////////////////////////////////// + QueryJoin lastAddedQueryJoin = addedQueryJoins.isEmpty() ? null : addedQueryJoins.get(addedQueryJoins.size() - 1); + if(sourceQueryJoin != null && lastAddedQueryJoin == null) + { + lastAddedQueryJoin = sourceQueryJoin; + } + addSubFilterForRecordSecurityLock(recordSecurityLock, tmpTable, securityFieldTableAlias, !chainIsInner, lastAddedQueryJoin); + + log("Finished evaluating recordSecurityLock"); + + return (addedQueryJoins); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void addSubFilterForRecordSecurityLock(RecordSecurityLock recordSecurityLock, QTableMetaData table, String tableNameOrAlias, boolean isOuter, QueryJoin sourceQueryJoin) + { + QSession session = QContext.getQSession(); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // check if the key type has an all-access key, and if so, if it's set to true for the current user/session // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QSecurityKeyType securityKeyType = instance.getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); + if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName())) + { + /////////////////////////////////////////////////////////////////////////////// + // if we have all-access on this key, then we don't need a criterion for it. // + /////////////////////////////////////////////////////////////////////////////// + if(session.hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) + { + if(sourceQueryJoin != null) + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // in case the queryJoin object is re-used between queries, and its security criteria need to be different (!!), reset it // + // this can be exposed in tests - maybe not entirely expected in real-world, but seems safe enough // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + sourceQueryJoin.withSecurityCriteria(new ArrayList<>()); + } + + return; + } + } + + ///////////////////////////////////////////////////////////////////////////////////////// + // for locks w/o a join chain, the lock fieldName will simply be a field on the table. // + // so just prepend that with the tableNameOrAlias. // + ///////////////////////////////////////////////////////////////////////////////////////// + String fieldName = tableNameOrAlias + "." + recordSecurityLock.getFieldName(); + if(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain())) + { + ///////////////////////////////////////////////////////////////////////////////// + // else, expect a "table.field" in the lock fieldName - but we want to replace // + // the table name part with a possible alias that we took in. // + ///////////////////////////////////////////////////////////////////////////////// + String[] parts = recordSecurityLock.getFieldName().split("\\."); + if(parts.length != 2) + { + dumpDebug(false, true); + throw new IllegalArgumentException("Mal-formatted recordSecurityLock fieldName for lock with joinNameChain in query: " + fieldName); + } + fieldName = tableNameOrAlias + "." + parts[1]; + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // else - get the key values from the session and decide what kind of criterion to build // + /////////////////////////////////////////////////////////////////////////////////////////// + QQueryFilter lockFilter = new QQueryFilter(); + List lockCriteria = new ArrayList<>(); + lockFilter.setCriteria(lockCriteria); + + QFieldType type = QFieldType.INTEGER; + try + { + JoinsContext.FieldAndTableNameOrAlias fieldAndTableNameOrAlias = getFieldAndTableNameOrAlias(fieldName); + type = fieldAndTableNameOrAlias.field().getType(); + } + catch(Exception e) + { + LOG.debug("Error getting field type... Trying Integer", e); + } + + List securityKeyValues = session.getSecurityKeyValues(recordSecurityLock.getSecurityKeyType(), type); + if(CollectionUtils.nullSafeIsEmpty(securityKeyValues)) + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // handle user with no values -- they can only see null values, and only iff the lock's null-value behavior is ALLOW // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior())) + { + lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_BLANK)); + } + else + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else, if no user/session values, and null-value behavior is deny, then setup a FALSE condition, to allow no rows. // + // todo - make some explicit contradiction here - maybe even avoid running the whole query - as you're not allowed ANY records // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IN, Collections.emptyList())); + } + } + else + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else, if user/session has some values, build an IN rule - // + // noting that if the lock's null-value behavior is ALLOW, then we actually want IS_NULL_OR_IN, not just IN // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior())) + { + lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_NULL_OR_IN, securityKeyValues)); + } + else + { + lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IN, securityKeyValues)); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if there's a sourceQueryJoin, then set the lockCriteria on that join - so it gets written into the JOIN ... ON clause // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(sourceQueryJoin != null) + { + sourceQueryJoin.withSecurityCriteria(lockCriteria); + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // we used to add an OR IS NULL for cases of an outer-join - but instead, this is now handled by putting the lockCriteria // + // into the join (see above) - so this check is probably deprecated. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /* + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this field is on the outer side of an outer join, then if we do a straight filter on it, then we're basically // + // nullifying the outer join... so for an outer join use-case, OR the security field criteria with a primary-key IS NULL // + // which will make missing rows from the join be found. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(isOuter) + { + lockFilter.setBooleanOperator(QQueryFilter.BooleanOperator.OR); + lockFilter.addCriteria(new QFilterCriteria(tableNameOrAlias + "." + table.getPrimaryKeyField(), QCriteriaOperator.IS_BLANK)); + } + */ + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + // If this filter isn't for a queryJoin, then just add it to the main list of security sub-filters // + ///////////////////////////////////////////////////////////////////////////////////////////////////// + this.securityFilter.addSubFilter(lockFilter); } } @@ -197,9 +538,9 @@ public class JoinsContext ** use this method to add to the list, instead of ever adding directly, as it's ** important do to that process step (and we've had bugs when it wasn't done). *******************************************************************************/ - private void addQueryJoin(QueryJoin queryJoin, String reason) throws QException + private void addQueryJoin(QueryJoin queryJoin, String reason, String logPrefix) throws QException { - log("Adding query join to context", + log(Objects.requireNonNullElse(logPrefix, "") + "Adding query join to context", logPair("reason", reason), logPair("joinTable", queryJoin.getJoinTable()), logPair("joinMetaData.name", () -> queryJoin.getJoinMetaData().getName()), @@ -208,34 +549,46 @@ public class JoinsContext ); this.queryJoins.add(queryJoin); processQueryJoin(queryJoin); + dumpDebug(false, false); } /******************************************************************************* ** If there are any joins in the context that don't have a join meta data, see - ** if we can find the JoinMetaData to use for them by looking at the main table's - ** exposed joins, and using their join paths. + ** if we can find the JoinMetaData to use for them by looking at all joins in the + ** instance, or at the main table's exposed joins, and using their join paths. *******************************************************************************/ - private void addJoinsFromExposedJoinPaths() throws QException + private void fillInMissingJoinMetaData() throws QException { + log("Begin adding missing join meta data"); + //////////////////////////////////////////////////////////////////////////////// // do a double-loop, to avoid concurrent modification on the queryJoins list. // // that is to say, we'll loop over that list, but possibly add things to it, // // in which case we'll set this flag, and break the inner loop, to go again. // //////////////////////////////////////////////////////////////////////////////// - boolean addedJoin; + Set processedQueryJoins = new HashSet<>(); + boolean addedJoin; do { addedJoin = false; for(QueryJoin queryJoin : queryJoins) { + if(processedQueryJoins.contains(queryJoin)) + { + continue; + } + processedQueryJoins.add(queryJoin); + /////////////////////////////////////////////////////////////////////////////////////////////// // if the join has joinMetaData, then we don't need to process it... unless it needs flipped // /////////////////////////////////////////////////////////////////////////////////////////////// QJoinMetaData joinMetaData = queryJoin.getJoinMetaData(); if(joinMetaData != null) { + log("- QueryJoin already has joinMetaData", logPair("joinMetaDataName", joinMetaData.getName())); + boolean isJoinLeftTableInQuery = false; String joinMetaDataLeftTable = joinMetaData.getLeftTable(); if(joinMetaDataLeftTable.equals(mainTableName)) @@ -265,7 +618,7 @@ public class JoinsContext ///////////////////////////////////////////////////////////////////////////////// if(!isJoinLeftTableInQuery) { - log("Flipping queryJoin because its leftTable wasn't found in the query", logPair("joinMetaDataName", joinMetaData.getName()), logPair("leftTable", joinMetaDataLeftTable)); + log("- - Flipping queryJoin because its leftTable wasn't found in the query", logPair("joinMetaDataName", joinMetaData.getName()), logPair("leftTable", joinMetaDataLeftTable)); queryJoin.setJoinMetaData(joinMetaData.flip()); } } @@ -275,11 +628,13 @@ public class JoinsContext // try to find a direct join between the main table and this table. // // if one is found, then put it (the meta data) on the query join. // ////////////////////////////////////////////////////////////////////// + log("- QueryJoin doesn't have metaData - looking for it", logPair("joinTableOrItsAlias", queryJoin.getJoinTableOrItsAlias())); + String baseTableName = Objects.requireNonNullElse(resolveTableNameOrAliasToTableName(queryJoin.getBaseTableOrAlias()), mainTableName); - QJoinMetaData found = findJoinMetaData(instance, baseTableName, queryJoin.getJoinTable()); + QJoinMetaData found = findJoinMetaData(baseTableName, queryJoin.getJoinTable(), true); if(found != null) { - log("Found joinMetaData - setting it in queryJoin", logPair("joinMetaDataName", found.getName()), logPair("baseTableName", baseTableName), logPair("joinTable", queryJoin.getJoinTable())); + log("- - Found joinMetaData - setting it in queryJoin", logPair("joinMetaDataName", found.getName()), logPair("baseTableName", baseTableName), logPair("joinTable", queryJoin.getJoinTable())); queryJoin.setJoinMetaData(found); } else @@ -293,7 +648,7 @@ public class JoinsContext { if(queryJoin.getJoinTable().equals(exposedJoin.getJoinTable())) { - log("Found an exposed join", logPair("mainTable", mainTableName), logPair("joinTable", queryJoin.getJoinTable()), logPair("joinPath", exposedJoin.getJoinPath())); + log("- - Found an exposed join", logPair("mainTable", mainTableName), logPair("joinTable", queryJoin.getJoinTable()), logPair("joinPath", exposedJoin.getJoinPath())); ///////////////////////////////////////////////////////////////////////////////////// // loop backward through the join path (from the joinTable back to the main table) // @@ -304,6 +659,7 @@ public class JoinsContext { String joinName = exposedJoin.getJoinPath().get(i); QJoinMetaData joinToAdd = instance.getJoin(joinName); + log("- - - evaluating joinPath element", logPair("i", i), logPair("joinName", joinName)); ///////////////////////////////////////////////////////////////////////////// // get the name from the opposite side of the join (flipping it if needed) // @@ -332,15 +688,22 @@ public class JoinsContext queryJoin.setBaseTableOrAlias(nextTable); } queryJoin.setJoinMetaData(joinToAdd); + log("- - - - this is the last element in the join path, so setting this joinMetaData on the original queryJoin"); } else { QueryJoin queryJoinToAdd = makeQueryJoinFromJoinAndTableNames(nextTable, tmpTable, joinToAdd); queryJoinToAdd.setType(queryJoin.getType()); addedAnyQueryJoins = true; - this.addQueryJoin(queryJoinToAdd, "forExposedJoin"); + log("- - - - this is not the last element in the join path, so adding a new query join:"); + addQueryJoin(queryJoinToAdd, "forExposedJoin", "- - - - - - "); + dumpDebug(false, false); } } + else + { + log("- - - - join doesn't need added to the query"); + } tmpTable = nextTable; } @@ -361,6 +724,7 @@ public class JoinsContext } while(addedJoin); + log("Done adding missing join meta data"); } @@ -370,12 +734,12 @@ public class JoinsContext *******************************************************************************/ private boolean doesJoinNeedAddedToQuery(String joinName) { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // look at all queryJoins already in context - if any have this join's name, then we don't need this join... // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // look at all queryJoins already in context - if any have this join's name, and aren't implicit-security joins, then we don't need this join... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// for(QueryJoin queryJoin : queryJoins) { - if(queryJoin.getJoinMetaData() != null && queryJoin.getJoinMetaData().getName().equals(joinName)) + if(queryJoin.getJoinMetaData() != null && queryJoin.getJoinMetaData().getName().equals(joinName) && !(queryJoin instanceof ImplicitQueryJoinForSecurityLock)) { return (false); } @@ -395,6 +759,7 @@ public class JoinsContext String tableNameOrAlias = queryJoin.getJoinTableOrItsAlias(); if(aliasToTableNameMap.containsKey(tableNameOrAlias)) { + dumpDebug(false, true); throw (new QException("Duplicate table name or alias: " + tableNameOrAlias)); } aliasToTableNameMap.put(tableNameOrAlias, joinTable.getName()); @@ -439,6 +804,7 @@ public class JoinsContext String[] parts = fieldName.split("\\."); if(parts.length != 2) { + dumpDebug(false, true); throw new IllegalArgumentException("Mal-formatted field name in query: " + fieldName); } @@ -449,6 +815,7 @@ public class JoinsContext QTableMetaData table = instance.getTable(tableName); if(table == null) { + dumpDebug(false, true); throw new IllegalArgumentException("Could not find table [" + tableName + "] in instance for query"); } return new FieldAndTableNameOrAlias(table.getField(baseFieldName), tableOrAlias); @@ -503,17 +870,17 @@ public class JoinsContext for(String filterTable : filterTables) { - log("Evaluating filterTable", logPair("filterTable", filterTable)); + log("Evaluating filter", logPair("filterTable", filterTable)); if(!aliasToTableNameMap.containsKey(filterTable) && !Objects.equals(mainTableName, filterTable)) { - log("- table not in query - adding it", logPair("filterTable", filterTable)); + log("- table not in query - adding a join for it", logPair("filterTable", filterTable)); boolean found = false; for(QJoinMetaData join : CollectionUtils.nonNullMap(QContext.getQInstance().getJoins()).values()) { QueryJoin queryJoin = makeQueryJoinFromJoinAndTableNames(mainTableName, filterTable, join); if(queryJoin != null) { - this.addQueryJoin(queryJoin, "forFilter (join found in instance)"); + addQueryJoin(queryJoin, "forFilter (join found in instance)", "- - "); found = true; break; } @@ -522,9 +889,13 @@ public class JoinsContext if(!found) { QueryJoin queryJoin = new QueryJoin().withJoinTable(filterTable).withType(QueryJoin.Type.INNER); - this.addQueryJoin(queryJoin, "forFilter (join not found in instance)"); + addQueryJoin(queryJoin, "forFilter (join not found in instance)", "- - "); } } + else + { + log("- table is already in query - not adding any joins", logPair("filterTable", filterTable)); + } } } @@ -566,6 +937,11 @@ public class JoinsContext getTableNameFromFieldNameAndAddToSet(criteria.getOtherFieldName(), filterTables); } + for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(filter.getOrderBys())) + { + getTableNameFromFieldNameAndAddToSet(orderBy.getFieldName(), filterTables); + } + for(QQueryFilter subFilter : CollectionUtils.nonNullList(filter.getSubFilters())) { populateFilterTablesSet(subFilter, filterTables); @@ -592,7 +968,7 @@ public class JoinsContext /******************************************************************************* ** *******************************************************************************/ - public QJoinMetaData findJoinMetaData(QInstance instance, String baseTableName, String joinTableName) + public QJoinMetaData findJoinMetaData(String baseTableName, String joinTableName, boolean useExposedJoins) { List matches = new ArrayList<>(); if(baseTableName != null) @@ -644,7 +1020,29 @@ public class JoinsContext } else if(matches.size() > 1) { - throw (new RuntimeException("More than 1 join was found between [" + baseTableName + "] and [" + joinTableName + "]. Specify which one in your QueryJoin.")); + //////////////////////////////////////////////////////////////////////////////// + // if we found more than one join, but we're allowed to useExposedJoins, then // + // see if we can tell which match to used based on the table's exposed joins // + //////////////////////////////////////////////////////////////////////////////// + if(useExposedJoins) + { + QTableMetaData mainTable = QContext.getQInstance().getTable(mainTableName); + for(ExposedJoin exposedJoin : mainTable.getExposedJoins()) + { + if(exposedJoin.getJoinTable().equals(joinTableName)) + { + // todo ... is it wrong to always use 0?? + return instance.getJoin(exposedJoin.getJoinPath().get(0)); + } + } + } + + /////////////////////////////////////////////// + // if we couldn't figure it out, then throw. // + /////////////////////////////////////////////// + dumpDebug(false, true); + throw (new RuntimeException("More than 1 join was found between [" + baseTableName + "] and [" + joinTableName + "] " + + (useExposedJoins ? "(and exposed joins didn't clarify which one to use). " : "") + "Specify which one in your QueryJoin.")); } return (null); @@ -669,4 +1067,63 @@ public class JoinsContext LOG.log(logLevel, message, null, logPairs); } + + + /******************************************************************************* + ** Print (to stdout, for easier reading) the object in a big table format for + ** debugging. Happens any time logLevel is > OFF. Not meant for loggly. + *******************************************************************************/ + private void dumpDebug(boolean isStart, boolean isEnd) + { + if(logLevel.equals(Level.OFF)) + { + return; + } + + int sm = 8; + int md = 30; + int lg = 50; + int overhead = 14; + int full = sm + 3 * md + lg + overhead; + + if(isStart) + { + System.out.println("\n" + StringUtils.safeTruncate("--- Start [main table: " + this.mainTableName + "] " + "-".repeat(full), full)); + } + + StringBuilder rs = new StringBuilder(); + String formatString = "| %-" + md + "s | %-" + md + "s %-" + md + "s | %-" + lg + "s | %-" + sm + "s |\n"; + rs.append(String.format(formatString, "Base Table", "Join Table", "(Alias)", "Join Meta Data", "Type")); + String dashesLg = "-".repeat(lg); + String dashesMd = "-".repeat(md); + String dashesSm = "-".repeat(sm); + rs.append(String.format(formatString, dashesMd, dashesMd, dashesMd, dashesLg, dashesSm)); + if(CollectionUtils.nullSafeHasContents(queryJoins)) + { + for(QueryJoin queryJoin : queryJoins) + { + rs.append(String.format( + formatString, + StringUtils.hasContent(queryJoin.getBaseTableOrAlias()) ? StringUtils.safeTruncate(queryJoin.getBaseTableOrAlias(), md) : "--", + StringUtils.safeTruncate(queryJoin.getJoinTable(), md), + (StringUtils.hasContent(queryJoin.getAlias()) ? "(" + StringUtils.safeTruncate(queryJoin.getAlias(), md - 2) + ")" : ""), + queryJoin.getJoinMetaData() == null ? "--" : StringUtils.safeTruncate(queryJoin.getJoinMetaData().getName(), lg), + queryJoin.getType())); + } + } + else + { + rs.append(String.format(formatString, "-empty-", "", "", "", "")); + } + + System.out.print(rs); + if(isEnd) + { + System.out.println(StringUtils.safeTruncate("--- End " + "-".repeat(full), full) + "\n"); + } + else + { + System.out.println(StringUtils.safeTruncate("-".repeat(full), full)); + } + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java index 6ce122bb..36933402 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QQueryFilter.java @@ -136,7 +136,7 @@ public class QQueryFilter implements Serializable, Cloneable /******************************************************************************* - ** + ** recursively look at both this filter, and any sub-filters it may have. *******************************************************************************/ public boolean hasAnyCriteria() { diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryJoin.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryJoin.java index c1e103e3..a2ef66ad 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryJoin.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryJoin.java @@ -22,6 +22,8 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -49,6 +51,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; ** specific joinMetaData to use must be set. The joinMetaData field can also be ** used instead of specify joinTable and baseTableOrAlias, but only for cases ** where the baseTable is not an alias. + ** + ** The securityCriteria member, in general, is meant to be populated when a + ** JoinsContext is constructed before executing a query, and not meant to be set + ** by users. *******************************************************************************/ public class QueryJoin { @@ -59,13 +65,30 @@ public class QueryJoin private boolean select = false; private Type type = Type.INNER; + private List securityCriteria = new ArrayList<>(); + /******************************************************************************* - ** + ** define the types of joins - INNER, LEFT, RIGHT, or FULL. *******************************************************************************/ public enum Type - {INNER, LEFT, RIGHT, FULL} + { + INNER, + LEFT, + RIGHT, + FULL; + + + + /******************************************************************************* + ** check if a join is an OUTER type (LEFT or RIGHT). + *******************************************************************************/ + public static boolean isOuter(Type type) + { + return (LEFT == type || RIGHT == type); + } + } @@ -348,4 +371,50 @@ public class QueryJoin return (this); } + + + /******************************************************************************* + ** Getter for securityCriteria + *******************************************************************************/ + public List getSecurityCriteria() + { + return (this.securityCriteria); + } + + + + /******************************************************************************* + ** Setter for securityCriteria + *******************************************************************************/ + public void setSecurityCriteria(List securityCriteria) + { + this.securityCriteria = securityCriteria; + } + + + + /******************************************************************************* + ** Fluent setter for securityCriteria + *******************************************************************************/ + public QueryJoin withSecurityCriteria(List securityCriteria) + { + this.securityCriteria = securityCriteria; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for securityCriteria + *******************************************************************************/ + public QueryJoin withSecurityCriteria(QFilterCriteria securityCriteria) + { + if(this.securityCriteria == null) + { + this.securityCriteria = new ArrayList<>(); + } + this.securityCriteria.add(securityCriteria); + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java index f1d6827b..009a6981 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java @@ -226,16 +226,7 @@ public class MemoryRecordStore { QTableMetaData nextTable = qInstance.getTable(queryJoin.getJoinTable()); Collection nextTableRecords = getTableData(nextTable).values(); - - QJoinMetaData joinMetaData = Objects.requireNonNullElseGet(queryJoin.getJoinMetaData(), () -> - { - QJoinMetaData found = joinsContext.findJoinMetaData(qInstance, input.getTableName(), queryJoin.getJoinTable()); - if(found == null) - { - throw (new RuntimeException("Could not find a join between tables [" + input.getTableName() + "][" + queryJoin.getJoinTable() + "]")); - } - return (found); - }); + QJoinMetaData joinMetaData = Objects.requireNonNull(queryJoin.getJoinMetaData(), () -> "Could not find a join between tables [" + leftTable + "][" + queryJoin.getJoinTable() + "]"); List nextLevelProduct = new ArrayList<>(); for(QRecord productRecord : crossProduct) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/AbstractExtractStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/AbstractExtractStep.java index ce227961..516f7d6a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/AbstractExtractStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/AbstractExtractStep.java @@ -112,4 +112,17 @@ public abstract class AbstractExtractStep implements BackendStep this.limit = limit; } + + + /******************************************************************************* + ** Create the record pipe to be used for this process step. + ** + ** Here in case a subclass needs a different type of pipe - for example, a + ** DistinctFilteringRecordPipe. + *******************************************************************************/ + public RecordPipe createRecordPipe(RunBackendStepInput runBackendStepInput, Integer overrideCapacity) + { + return (new RecordPipe(overrideCapacity)); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java index 1f3e776d..c6038ae9 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/ExtractViaQueryStep.java @@ -24,8 +24,14 @@ package com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwit import java.io.IOException; import java.io.Serializable; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import com.kingsrook.qqq.backend.core.actions.reporting.DistinctFilteringRecordPipe; +import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -36,9 +42,13 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; 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.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -105,6 +115,7 @@ public class ExtractViaQueryStep extends AbstractExtractStep QueryInput queryInput = new QueryInput(); queryInput.setTableName(runBackendStepInput.getValueString(FIELD_SOURCE_TABLE)); queryInput.setFilter(filterClone); + getQueryJoinsForOrderByIfNeeded(queryFilter).forEach(queryJoin -> queryInput.withQueryJoin(queryJoin)); queryInput.setSelectDistinct(true); queryInput.setRecordPipe(getRecordPipe()); queryInput.setAsyncJobCallback(runBackendStepInput.getAsyncJobCallback()); @@ -135,6 +146,45 @@ public class ExtractViaQueryStep extends AbstractExtractStep + /******************************************************************************* + ** If the queryFilter has order-by fields from a joinTable, then create QueryJoins + ** for each such table - marked as LEFT, and select=true. + ** + ** This is under the rationale that, the filter would have come from the frontend, + ** which would be doing outer-join semantics for a column being shown (but not filtered by). + ** If the table IS filtered by, it's still OK to do a LEFT, as we'll only get rows + ** that match. + ** + ** Also, they are being select=true'ed so that the DISTINCT clause works (since + ** process queries always try to be DISTINCT). + *******************************************************************************/ + private List getQueryJoinsForOrderByIfNeeded(QQueryFilter queryFilter) + { + if(queryFilter == null) + { + return (Collections.emptyList()); + } + + List rs = new ArrayList<>(); + Set addedTables = new HashSet<>(); + for(QFilterOrderBy filterOrderBy : CollectionUtils.nonNullList(queryFilter.getOrderBys())) + { + if(filterOrderBy.getFieldName().contains(".")) + { + String tableName = filterOrderBy.getFieldName().split("\\.")[0]; + if(!addedTables.contains(tableName)) + { + rs.add(new QueryJoin(tableName).withType(QueryJoin.Type.LEFT).withSelect(true)); + } + addedTables.add(tableName); + } + } + + return (rs); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -144,6 +194,7 @@ public class ExtractViaQueryStep extends AbstractExtractStep CountInput countInput = new CountInput(); countInput.setTableName(runBackendStepInput.getValueString(FIELD_SOURCE_TABLE)); countInput.setFilter(queryFilter); + getQueryJoinsForOrderByIfNeeded(queryFilter).forEach(queryJoin -> countInput.withQueryJoin(queryJoin)); countInput.setIncludeDistinctCount(true); CountOutput countOutput = new CountAction().execute(countInput); Integer count = countOutput.getDistinctCount(); @@ -243,4 +294,33 @@ public class ExtractViaQueryStep extends AbstractExtractStep } } + + + /******************************************************************************* + ** Create the record pipe to be used for this process step. + ** + *******************************************************************************/ + @Override + public RecordPipe createRecordPipe(RunBackendStepInput runBackendStepInput, Integer overrideCapacity) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the filter has order-bys from a join-table, then we have to include that join-table in the SELECT clause, // + // which means we need to do distinct "manually", e.g., via a DistinctFilteringRecordPipe // + // todo - really, wouldn't this only be if it's a many-join? but that's not completely trivial to detect, given join-chains... // + // as it is, we may end up using DistinctPipe in some cases that we need it - which isn't an error, just slightly sub-optimal. // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + List queryJoinsForOrderByIfNeeded = getQueryJoinsForOrderByIfNeeded(queryFilter); + boolean needDistinctPipe = CollectionUtils.nullSafeHasContents(queryJoinsForOrderByIfNeeded); + + if(needDistinctPipe) + { + String sourceTableName = runBackendStepInput.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE); + QTableMetaData sourceTable = runBackendStepInput.getInstance().getTable(sourceTableName); + return (new DistinctFilteringRecordPipe(new UniqueKey(sourceTable.getPrimaryKeyField()), overrideCapacity)); + } + else + { + return (super.createRecordPipe(runBackendStepInput, overrideCapacity)); + } + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLExecuteStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLExecuteStep.java index d842cf03..2fb6c34f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLExecuteStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLExecuteStep.java @@ -34,7 +34,6 @@ import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.audits.AuditInput; -import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; @@ -77,17 +76,19 @@ public class StreamedETLExecuteStep extends BaseStreamedETLStep implements Backe loadStep.setTransformStep(transformStep); + extractStep.preRun(runBackendStepInput, runBackendStepOutput); + transformStep.preRun(runBackendStepInput, runBackendStepOutput); + loadStep.preRun(runBackendStepInput, runBackendStepOutput); + ///////////////////////////////////////////////////////////////////////////// // let the load step override the capacity for the record pipe. // // this is useful for slower load steps - so that the extract step doesn't // // fill the pipe, then timeout waiting for all the records to be consumed, // // before it can put more records in. // ///////////////////////////////////////////////////////////////////////////// - RecordPipe recordPipe; - Integer overrideRecordPipeCapacity = loadStep.getOverrideRecordPipeCapacity(runBackendStepInput); + Integer overrideRecordPipeCapacity = loadStep.getOverrideRecordPipeCapacity(runBackendStepInput); if(overrideRecordPipeCapacity != null) { - recordPipe = new RecordPipe(overrideRecordPipeCapacity); LOG.debug("per " + loadStep.getClass().getName() + ", we are overriding record pipe capacity to: " + overrideRecordPipeCapacity); } else @@ -95,20 +96,12 @@ public class StreamedETLExecuteStep extends BaseStreamedETLStep implements Backe overrideRecordPipeCapacity = transformStep.getOverrideRecordPipeCapacity(runBackendStepInput); if(overrideRecordPipeCapacity != null) { - recordPipe = new RecordPipe(overrideRecordPipeCapacity); LOG.debug("per " + transformStep.getClass().getName() + ", we are overriding record pipe capacity to: " + overrideRecordPipeCapacity); } - else - { - recordPipe = new RecordPipe(); - } } + RecordPipe recordPipe = extractStep.createRecordPipe(runBackendStepInput, overrideRecordPipeCapacity); extractStep.setRecordPipe(recordPipe); - extractStep.preRun(runBackendStepInput, runBackendStepOutput); - - transformStep.preRun(runBackendStepInput, runBackendStepOutput); - loadStep.preRun(runBackendStepInput, runBackendStepOutput); ///////////////////////////////////////////////////////////////////////////// // open a transaction for the whole process, if that's the requested level // diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLPreviewStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLPreviewStep.java index 4921c9ce..f8edde2f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLPreviewStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLPreviewStep.java @@ -72,15 +72,6 @@ public class StreamedETLPreviewStep extends BaseStreamedETLStep implements Backe return; } - ////////////////////////////// - // set up the extract steps // - ////////////////////////////// - AbstractExtractStep extractStep = getExtractStep(runBackendStepInput); - RecordPipe recordPipe = new RecordPipe(); - extractStep.setLimit(limit); - extractStep.setRecordPipe(recordPipe); - extractStep.preRun(runBackendStepInput, runBackendStepOutput); - ///////////////////////////////////////////////////////////////// // if we're running inside an automation, then skip this step. // ///////////////////////////////////////////////////////////////// @@ -90,6 +81,19 @@ public class StreamedETLPreviewStep extends BaseStreamedETLStep implements Backe return; } + ///////////////////////////// + // set up the extract step // + ///////////////////////////// + AbstractExtractStep extractStep = getExtractStep(runBackendStepInput); + extractStep.setLimit(limit); + extractStep.preRun(runBackendStepInput, runBackendStepOutput); + + ////////////////////////////////////////// + // set up a record pipe for the process // + ////////////////////////////////////////// + RecordPipe recordPipe = extractStep.createRecordPipe(runBackendStepInput, null); + extractStep.setRecordPipe(recordPipe); + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if skipping frontend steps, skip this action - // // but, if inside an (ideally, only async) API call, at least do the count, so status calls can get x of y status // diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLValidateStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLValidateStep.java index 63d858c1..d9b6dd34 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLValidateStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLValidateStep.java @@ -77,17 +77,29 @@ public class StreamedETLValidateStep extends BaseStreamedETLStep implements Back ////////////////////////////////////////////////////////////////////////////////////////////////////////////// moveReviewStepAfterValidateStep(runBackendStepOutput); + AbstractExtractStep extractStep = getExtractStep(runBackendStepInput); + AbstractTransformStep transformStep = getTransformStep(runBackendStepInput); + + runBackendStepInput.getAsyncJobCallback().updateStatus("Validating Records"); + ////////////////////////////////////////////////////////// // basically repeat the preview step, but with no limit // ////////////////////////////////////////////////////////// - runBackendStepInput.getAsyncJobCallback().updateStatus("Validating Records"); - RecordPipe recordPipe = new RecordPipe(); - AbstractExtractStep extractStep = getExtractStep(runBackendStepInput); extractStep.setLimit(null); - extractStep.setRecordPipe(recordPipe); extractStep.preRun(runBackendStepInput, runBackendStepOutput); - AbstractTransformStep transformStep = getTransformStep(runBackendStepInput); + ////////////////////////////////////////// + // set up a record pipe for the process // + ////////////////////////////////////////// + Integer overrideRecordPipeCapacity = transformStep.getOverrideRecordPipeCapacity(runBackendStepInput); + if(overrideRecordPipeCapacity != null) + { + LOG.debug("per " + transformStep.getClass().getName() + ", we are overriding record pipe capacity to: " + overrideRecordPipeCapacity); + } + + RecordPipe recordPipe = extractStep.createRecordPipe(runBackendStepInput, null); + extractStep.setRecordPipe(recordPipe); + transformStep.preRun(runBackendStepInput, runBackendStepOutput); List previewRecordList = new ArrayList<>(); diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java index 8d223435..a8ea14f3 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java @@ -52,9 +52,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate; import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy; import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByAggregate; import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByGroupBy; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.ImplicitQueryJoinForSecurityLock; import com.kingsrook.qqq.backend.core.model.actions.tables.query.JoinsContext; -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.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; @@ -68,12 +66,10 @@ 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.security.QSecurityKeyType; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLockFilters; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.querystats.QueryStat; -import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.ValueUtils; @@ -212,7 +208,7 @@ public abstract class AbstractRDBMSAction implements QActionInterface /******************************************************************************* ** *******************************************************************************/ - protected String makeFromClause(QInstance instance, String tableName, JoinsContext joinsContext) throws QException + protected String makeFromClause(QInstance instance, String tableName, JoinsContext joinsContext, List params) { StringBuilder rs = new StringBuilder(escapeIdentifier(getTableName(instance.getTable(tableName))) + " AS " + escapeIdentifier(tableName)); @@ -229,17 +225,9 @@ public abstract class AbstractRDBMSAction implements QActionInterface //////////////////////////////////////////////////////////// // find the join in the instance, to set the 'on' clause // //////////////////////////////////////////////////////////// - List joinClauseList = new ArrayList<>(); - String baseTableName = Objects.requireNonNullElse(joinsContext.resolveTableNameOrAliasToTableName(queryJoin.getBaseTableOrAlias()), tableName); - QJoinMetaData joinMetaData = Objects.requireNonNullElseGet(queryJoin.getJoinMetaData(), () -> - { - QJoinMetaData found = joinsContext.findJoinMetaData(instance, baseTableName, queryJoin.getJoinTable()); - if(found == null) - { - throw (new RuntimeException("Could not find a join between tables [" + baseTableName + "][" + queryJoin.getJoinTable() + "]")); - } - return (found); - }); + List joinClauseList = new ArrayList<>(); + String baseTableName = Objects.requireNonNullElse(joinsContext.resolveTableNameOrAliasToTableName(queryJoin.getBaseTableOrAlias()), tableName); + QJoinMetaData joinMetaData = Objects.requireNonNull(queryJoin.getJoinMetaData(), () -> "Could not find a join between tables [" + baseTableName + "][" + queryJoin.getJoinTable() + "]"); for(JoinOn joinOn : joinMetaData.getJoinOns()) { @@ -270,6 +258,14 @@ public abstract class AbstractRDBMSAction implements QActionInterface + " = " + escapeIdentifier(joinTableOrAlias) + "." + escapeIdentifier(getColumnName((rightTable.getField(joinOn.getRightField()))))); } + + if(CollectionUtils.nullSafeHasContents(queryJoin.getSecurityCriteria())) + { + String securityOnClause = getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(joinsContext, queryJoin.getSecurityCriteria(), QQueryFilter.BooleanOperator.AND, params); + LOG.debug("Wrote securityOnClause", logPair("clause", securityOnClause)); + joinClauseList.add(securityOnClause); + } + rs.append(" ON ").append(StringUtils.join(" AND ", joinClauseList)); } @@ -285,34 +281,66 @@ public abstract class AbstractRDBMSAction implements QActionInterface *******************************************************************************/ private List sortQueryJoinsForFromClause(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); - List rs = new ArrayList<>(); - Set seenTables = new HashSet<>(); - seenTables.add(mainTableName); + /////////////////////////////////////////////////////////////////////////////////////////////////// + // 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 next = iterator.next(); - if((StringUtils.hasContent(next.getBaseTableOrAlias()) && seenTables.contains(next.getBaseTableOrAlias())) || seenTables.contains(next.getJoinTable())) + 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) { - rs.add(next); - if(StringUtils.hasContent(next.getBaseTableOrAlias())) + 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)) { - seenTables.add(next.getBaseTableOrAlias()); + seenTablesOrAliases.add(baseTableOrAlias); } - seenTables.add(next.getJoinTable()); + + 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); @@ -321,35 +349,19 @@ public abstract class AbstractRDBMSAction implements QActionInterface /******************************************************************************* - ** method that sub-classes should call to make a full WHERE clause, including - ** security clauses. + ** Method to make a full WHERE clause. + ** + ** Note that criteria for security are assumed to have been added to the filter + ** during the construction of the JoinsContext. *******************************************************************************/ - protected String makeWhereClause(QInstance instance, QSession session, QTableMetaData table, JoinsContext joinsContext, QQueryFilter filter, List params) throws IllegalArgumentException, QException - { - String whereClauseWithoutSecurity = makeWhereClauseWithoutSecurity(instance, table, joinsContext, filter, params); - QQueryFilter securityFilter = getSecurityFilter(instance, session, table, joinsContext); - if(!securityFilter.hasAnyCriteria()) - { - return (whereClauseWithoutSecurity); - } - String securityWhereClause = makeWhereClauseWithoutSecurity(instance, table, joinsContext, securityFilter, params); - return ("(" + whereClauseWithoutSecurity + ") AND (" + securityWhereClause + ")"); - } - - - - /******************************************************************************* - ** private method for making the part of a where clause that gets AND'ed to the - ** security clause. Recursively handles sub-clauses. - *******************************************************************************/ - private String makeWhereClauseWithoutSecurity(QInstance instance, QTableMetaData table, JoinsContext joinsContext, QQueryFilter filter, List params) throws IllegalArgumentException, QException + protected String makeWhereClause(JoinsContext joinsContext, QQueryFilter filter, List params) throws IllegalArgumentException { if(filter == null || !filter.hasAnyCriteria()) { return ("1 = 1"); } - String clause = getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(instance, table, joinsContext, filter.getCriteria(), filter.getBooleanOperator(), params); + String clause = getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(joinsContext, filter.getCriteria(), filter.getBooleanOperator(), params); if(!CollectionUtils.nullSafeHasContents(filter.getSubFilters())) { /////////////////////////////////////////////////////////////// @@ -368,7 +380,7 @@ public abstract class AbstractRDBMSAction implements QActionInterface } for(QQueryFilter subFilter : filter.getSubFilters()) { - String subClause = makeWhereClauseWithoutSecurity(instance, table, joinsContext, subFilter, params); + String subClause = makeWhereClause(joinsContext, subFilter, params); if(StringUtils.hasContent(subClause)) { clauses.add("(" + subClause + ")"); @@ -379,146 +391,10 @@ public abstract class AbstractRDBMSAction implements QActionInterface - /******************************************************************************* - ** Build a QQueryFilter to apply record-level security to the query. - ** Note, it may be empty, if there are no lock fields, or all are all-access. - *******************************************************************************/ - private QQueryFilter getSecurityFilter(QInstance instance, QSession session, QTableMetaData table, JoinsContext joinsContext) - { - QQueryFilter securityFilter = new QQueryFilter(); - securityFilter.setBooleanOperator(QQueryFilter.BooleanOperator.AND); - - for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(table.getRecordSecurityLocks()))) - { - // todo - uh, if it's a RIGHT (or FULL) join, then, this should be isOuter = true, right? - boolean isOuter = false; - addSubFilterForRecordSecurityLock(instance, session, table, securityFilter, recordSecurityLock, joinsContext, table.getName(), isOuter); - } - - for(QueryJoin queryJoin : CollectionUtils.nonNullList(joinsContext.getQueryJoins())) - { - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // for user-added joins, we want to add their security-locks to the query // - // but if a join was implicitly added because it's needed to find a security lock on table being queried, // - // don't add additional layers of locks for each join table. that's the idea here at least. // - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(queryJoin instanceof ImplicitQueryJoinForSecurityLock) - { - continue; - } - - QTableMetaData joinTable = instance.getTable(queryJoin.getJoinTable()); - for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks()))) - { - boolean isOuter = queryJoin.getType().equals(QueryJoin.Type.LEFT); // todo full? - addSubFilterForRecordSecurityLock(instance, session, joinTable, securityFilter, recordSecurityLock, joinsContext, queryJoin.getJoinTableOrItsAlias(), isOuter); - } - } - - return (securityFilter); - } - - - /******************************************************************************* ** *******************************************************************************/ - private static void addSubFilterForRecordSecurityLock(QInstance instance, QSession session, QTableMetaData table, QQueryFilter securityFilter, RecordSecurityLock recordSecurityLock, JoinsContext joinsContext, String tableNameOrAlias, boolean isOuter) - { - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // check if the key type has an all-access key, and if so, if it's set to true for the current user/session // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - QSecurityKeyType securityKeyType = instance.getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); - if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName())) - { - if(session.hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) - { - /////////////////////////////////////////////////////////////////////////////// - // if we have all-access on this key, then we don't need a criterion for it. // - /////////////////////////////////////////////////////////////////////////////// - return; - } - } - - String fieldName = tableNameOrAlias + "." + recordSecurityLock.getFieldName(); - if(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain())) - { - fieldName = recordSecurityLock.getFieldName(); - } - - /////////////////////////////////////////////////////////////////////////////////////////// - // else - get the key values from the session and decide what kind of criterion to build // - /////////////////////////////////////////////////////////////////////////////////////////// - QQueryFilter lockFilter = new QQueryFilter(); - List lockCriteria = new ArrayList<>(); - lockFilter.setCriteria(lockCriteria); - - QFieldType type = QFieldType.INTEGER; - try - { - JoinsContext.FieldAndTableNameOrAlias fieldAndTableNameOrAlias = joinsContext.getFieldAndTableNameOrAlias(fieldName); - type = fieldAndTableNameOrAlias.field().getType(); - } - catch(Exception e) - { - LOG.debug("Error getting field type... Trying Integer", e); - } - - List securityKeyValues = session.getSecurityKeyValues(recordSecurityLock.getSecurityKeyType(), type); - if(CollectionUtils.nullSafeIsEmpty(securityKeyValues)) - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // handle user with no values -- they can only see null values, and only iff the lock's null-value behavior is ALLOW // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior())) - { - lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_BLANK)); - } - else - { - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else, if no user/session values, and null-value behavior is deny, then setup a FALSE condition, to allow no rows. // - // todo - make some explicit contradiction here - maybe even avoid running the whole query - as you're not allowed ANY records // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IN, Collections.emptyList())); - } - } - else - { - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else, if user/session has some values, build an IN rule - // - // noting that if the lock's null-value behavior is ALLOW, then we actually want IS_NULL_OR_IN, not just IN // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior())) - { - lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_NULL_OR_IN, securityKeyValues)); - } - else - { - lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IN, securityKeyValues)); - } - } - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // if this field is on the outer side of an outer join, then if we do a straight filter on it, then we're basically // - // nullifying the outer join... so for an outer join use-case, OR the security field criteria with a primary-key IS NULL // - // which will make missing rows from the join be found. // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(isOuter) - { - lockFilter.setBooleanOperator(QQueryFilter.BooleanOperator.OR); - lockFilter.addCriteria(new QFilterCriteria(tableNameOrAlias + "." + table.getPrimaryKeyField(), QCriteriaOperator.IS_BLANK)); - } - - securityFilter.addSubFilter(lockFilter); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private String getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(QInstance instance, QTableMetaData table, JoinsContext joinsContext, List criteria, QQueryFilter.BooleanOperator booleanOperator, List params) throws IllegalArgumentException + private String getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(JoinsContext joinsContext, List criteria, QQueryFilter.BooleanOperator booleanOperator, List params) throws IllegalArgumentException { List clauses = new ArrayList<>(); for(QFilterCriteria criterion : criteria) @@ -1123,4 +999,20 @@ public abstract class AbstractRDBMSAction implements QActionInterface } } + + + /******************************************************************************* + ** Either clone the input filter (so we can change it safely), or return a new blank filter. + *******************************************************************************/ + protected QQueryFilter clonedOrNewFilter(QQueryFilter filter) + { + if(filter == null) + { + return (new QQueryFilter()); + } + else + { + return (filter.clone()); + } + } } diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateAction.java index ce720120..c7ebc8ba 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateAction.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateAction.java @@ -59,6 +59,7 @@ public class RDBMSAggregateAction extends AbstractRDBMSAction implements Aggrega private ActionTimeoutHelper actionTimeoutHelper; + /******************************************************************************* ** *******************************************************************************/ @@ -68,16 +69,17 @@ public class RDBMSAggregateAction extends AbstractRDBMSAction implements Aggrega { QTableMetaData table = aggregateInput.getTable(); - JoinsContext joinsContext = new JoinsContext(aggregateInput.getInstance(), table.getName(), aggregateInput.getQueryJoins(), aggregateInput.getFilter()); - String fromClause = makeFromClause(aggregateInput.getInstance(), table.getName(), joinsContext); + QQueryFilter filter = clonedOrNewFilter(aggregateInput.getFilter()); + JoinsContext joinsContext = new JoinsContext(aggregateInput.getInstance(), table.getName(), aggregateInput.getQueryJoins(), filter); + + List params = new ArrayList<>(); + + String fromClause = makeFromClause(aggregateInput.getInstance(), table.getName(), joinsContext, params); List selectClauses = buildSelectClauses(aggregateInput, joinsContext); String sql = "SELECT " + StringUtils.join(", ", selectClauses) - + " FROM " + fromClause; - - QQueryFilter filter = aggregateInput.getFilter(); - List params = new ArrayList<>(); - sql += " WHERE " + makeWhereClause(aggregateInput.getInstance(), aggregateInput.getSession(), table, joinsContext, filter, params); + + " FROM " + fromClause + + " WHERE " + makeWhereClause(joinsContext, filter, params); if(CollectionUtils.nullSafeHasContents(aggregateInput.getGroupBys())) { diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSCountAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSCountAction.java index 7177a4a1..ba167674 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSCountAction.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSCountAction.java @@ -62,7 +62,8 @@ public class RDBMSCountAction extends AbstractRDBMSAction implements CountInterf { QTableMetaData table = countInput.getTable(); - JoinsContext joinsContext = new JoinsContext(countInput.getInstance(), countInput.getTableName(), countInput.getQueryJoins(), countInput.getFilter()); + QQueryFilter filter = clonedOrNewFilter(countInput.getFilter()); + JoinsContext joinsContext = new JoinsContext(countInput.getInstance(), countInput.getTableName(), countInput.getQueryJoins(), filter); JoinsContext.FieldAndTableNameOrAlias fieldAndTableNameOrAlias = joinsContext.getFieldAndTableNameOrAlias(table.getPrimaryKeyField()); boolean requiresDistinct = doesSelectClauseRequireDistinct(table); @@ -74,12 +75,10 @@ public class RDBMSCountAction extends AbstractRDBMSAction implements CountInterf clausePrefix = "SELECT COUNT(DISTINCT (" + primaryKeyColumn + ")) AS distinct_count, COUNT(*)"; } - String sql = clausePrefix + " AS record_count FROM " - + makeFromClause(countInput.getInstance(), table.getName(), joinsContext); - - QQueryFilter filter = countInput.getFilter(); List params = new ArrayList<>(); - sql += " WHERE " + makeWhereClause(countInput.getInstance(), countInput.getSession(), table, joinsContext, filter, params); + String sql = clausePrefix + " AS record_count " + + " FROM " + makeFromClause(countInput.getInstance(), table.getName(), joinsContext, params) + + " WHERE " + makeWhereClause(joinsContext, filter, params); // todo sql customization - can edit sql and/or param list setSqlAndJoinsInQueryStat(sql, joinsContext); diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSDeleteAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSDeleteAction.java index 734c3202..baec4f0a 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSDeleteAction.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSDeleteAction.java @@ -268,7 +268,7 @@ public class RDBMSDeleteAction extends AbstractRDBMSAction implements DeleteInte String tableName = getTableName(table); JoinsContext joinsContext = new JoinsContext(deleteInput.getInstance(), table.getName(), new ArrayList<>(), deleteInput.getQueryFilter()); - String whereClause = makeWhereClause(deleteInput.getInstance(), deleteInput.getSession(), table, joinsContext, filter, params); + String whereClause = makeWhereClause(joinsContext, filter, params); // todo sql customization - can edit sql and/or param list? String sql = "DELETE FROM " diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryAction.java index fc5dfa79..1242bd24 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryAction.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryAction.java @@ -79,13 +79,12 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf StringBuilder sql = new StringBuilder(makeSelectClause(queryInput)); - JoinsContext joinsContext = new JoinsContext(queryInput.getInstance(), tableName, queryInput.getQueryJoins(), queryInput.getFilter()); - sql.append(" FROM ").append(makeFromClause(queryInput.getInstance(), tableName, joinsContext)); + QQueryFilter filter = clonedOrNewFilter(queryInput.getFilter()); + JoinsContext joinsContext = new JoinsContext(queryInput.getInstance(), tableName, queryInput.getQueryJoins(), filter); - QQueryFilter filter = queryInput.getFilter(); List params = new ArrayList<>(); - - sql.append(" WHERE ").append(makeWhereClause(queryInput.getInstance(), queryInput.getSession(), table, joinsContext, filter, params)); + sql.append(" FROM ").append(makeFromClause(queryInput.getInstance(), tableName, joinsContext, params)); + sql.append(" WHERE ").append(makeWhereClause(joinsContext, filter, params)); if(filter != null && CollectionUtils.nullSafeHasContents(filter.getOrderBys())) { diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportActionWithinRDBMSTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportActionWithinRDBMSTest.java new file mode 100644 index 00000000..8100c4c1 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportActionWithinRDBMSTest.java @@ -0,0 +1,85 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.actions.reporting; + + +import java.io.ByteArrayOutputStream; +import java.util.List; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportOutput; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.module.rdbms.TestUtils; +import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSActionTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/******************************************************************************* + ** Test some harder exports, using RDBMS backend. + *******************************************************************************/ +public class ExportActionWithinRDBMSTest extends RDBMSActionTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + public void beforeEach() throws Exception + { + super.primeTestDatabase(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testIncludingFieldsFromExposedJoinTableWithTwoJoinsToMainTable() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ExportInput exportInput = new ExportInput(); + exportInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + exportInput.setReportFormat(ReportFormat.CSV); + exportInput.setReportOutputStream(baos); + exportInput.setQueryFilter(new QQueryFilter()); + exportInput.setFieldNames(List.of("id", "storeId", "billToPersonId", "currentOrderInstructionsId", TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS + ".id", TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS + ".instructions")); + ExportOutput exportOutput = new ExportAction().execute(exportInput); + + assertNotNull(exportOutput); + + /////////////////////////////////////////////////////////////////////////// + // if there was an exception running the query, we get back 0 records... // + /////////////////////////////////////////////////////////////////////////// + assertEquals(3, exportOutput.getRecordCount()); + } + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java index d882807a..ccf5dd86 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java @@ -243,6 +243,7 @@ public class TestUtils .withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType(TABLE_NAME_STORE).withFieldName("storeId")) .withAssociation(new Association().withName("orderLine").withAssociatedTableName(TABLE_NAME_ORDER_LINE).withJoinName("orderJoinOrderLine")) .withExposedJoin(new ExposedJoin().withJoinTable(TABLE_NAME_ITEM).withJoinPath(List.of("orderJoinOrderLine", "orderLineJoinItem"))) + .withExposedJoin(new ExposedJoin().withJoinTable(TABLE_NAME_ORDER_INSTRUCTIONS).withJoinPath(List.of("orderJoinCurrentOrderInstructions"))) .withField(new QFieldMetaData("storeId", QFieldType.INTEGER).withBackendName("store_id").withPossibleValueSourceName(TABLE_NAME_STORE)) .withField(new QFieldMetaData("billToPersonId", QFieldType.INTEGER).withBackendName("bill_to_person_id").withPossibleValueSourceName(TABLE_NAME_PERSON)) .withField(new QFieldMetaData("shipToPersonId", QFieldType.INTEGER).withBackendName("ship_to_person_id").withPossibleValueSourceName(TABLE_NAME_PERSON)) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertActionTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertActionTest.java index f1bc1763..224b229a 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertActionTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertActionTest.java @@ -161,10 +161,10 @@ public class RDBMSInsertActionTest extends RDBMSActionTest insertInput.setRecords(List.of( new QRecord().withValue("storeId", 1).withValue("billToPersonId", 100).withValue("shipToPersonId", 200) - .withAssociatedRecord("orderLine", new QRecord().withValue("sku", "BASIC1").withValue("quantity", 1) + .withAssociatedRecord("orderLine", new QRecord().withValue("storeId", 1).withValue("sku", "BASIC1").withValue("quantity", 1) .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "LINE-EXT-1.1").withValue("value", "LINE-VAL-1"))) - .withAssociatedRecord("orderLine", new QRecord().withValue("sku", "BASIC2").withValue("quantity", 2) + .withAssociatedRecord("orderLine", new QRecord().withValue("storeId", 1).withValue("sku", "BASIC2").withValue("quantity", 2) .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "LINE-EXT-2.1").withValue("value", "LINE-VAL-2")) .withAssociatedRecord("extrinsics", new QRecord().withValue("key", "LINE-EXT-2.2").withValue("value", "LINE-VAL-3"))) )); diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java new file mode 100644 index 00000000..d096b1cb --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java @@ -0,0 +1,987 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.module.rdbms.actions; + + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +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.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder; +import com.kingsrook.qqq.backend.module.rdbms.TestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Tests on RDBMS - specifically dealing with Joins. + *******************************************************************************/ +public class RDBMSQueryActionJoinsTest extends RDBMSActionTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + public void beforeEach() throws Exception + { + super.primeTestDatabase(); + + AbstractRDBMSAction.setLogSQL(true); + AbstractRDBMSAction.setLogSQLOutput("system.out"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void afterEach() + { + AbstractRDBMSAction.setLogSQL(false); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private QueryInput initQueryRequest() + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_PERSON); + return queryInput; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFilterFromJoinTableImplicitly() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("personalIdCard.idNumber", QCriteriaOperator.EQUALS, "19800531"))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(1, queryOutput.getRecords().size(), "Query should find 1 rows"); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOneToOneInnerJoinWithoutWhere() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true)); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(3, queryOutput.getRecords().size(), "Join query should find 3 rows"); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tim") && r.getValueString("personalIdCard.idNumber").equals("19760528")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOneToOneLeftJoinWithoutWhere() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withType(QueryJoin.Type.LEFT).withSelect(true)); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(5, queryOutput.getRecords().size(), "Left Join query should find 5 rows"); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tim") && r.getValueString("personalIdCard.idNumber").equals("19760528")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Garret") && r.getValue("personalIdCard.idNumber") == null); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tyler") && r.getValue("personalIdCard.idNumber") == null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOneToOneRightJoinWithoutWhere() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withType(QueryJoin.Type.RIGHT).withSelect(true)); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(6, queryOutput.getRecords().size(), "Right Join query should find 6 rows"); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tim") && r.getValueString("personalIdCard.idNumber").equals("19760528")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValue("firstName") == null && r.getValueString("personalIdCard.idNumber").equals("123123123")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValue("firstName") == null && r.getValueString("personalIdCard.idNumber").equals("987987987")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValue("firstName") == null && r.getValueString("personalIdCard.idNumber").equals("456456456")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOneToOneInnerJoinWithWhere() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true)); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber", QCriteriaOperator.STARTS_WITH, "1980"))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(2, queryOutput.getRecords().size(), "Join query should find 2 rows"); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); + assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOneToOneInnerJoinWithOrderBy() throws QException + { + QInstance qInstance = TestUtils.defineInstance(); + QueryInput queryInput = initQueryRequest(); + queryInput.withQueryJoin(new QueryJoin(qInstance.getJoin(TestUtils.TABLE_NAME_PERSON + "Join" + StringUtils.ucFirst(TestUtils.TABLE_NAME_PERSONAL_ID_CARD))).withSelect(true)); + queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber"))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(3, queryOutput.getRecords().size(), "Join query should find 3 rows"); + List idNumberListFromQuery = queryOutput.getRecords().stream().map(r -> r.getValueString(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")).toList(); + assertEquals(List.of("19760528", "19800515", "19800531"), idNumberListFromQuery); + + ///////////////////////// + // repeat, sorted desc // + ///////////////////////// + queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber", false))); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(3, queryOutput.getRecords().size(), "Join query should find 3 rows"); + idNumberListFromQuery = queryOutput.getRecords().stream().map(r -> r.getValueString(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")).toList(); + assertEquals(List.of("19800531", "19800515", "19760528"), idNumberListFromQuery); + } + + + + /******************************************************************************* + ** In the prime data, we've got 1 order line set up with an item from a different + ** store than its order. Write a query to find such a case. + *******************************************************************************/ + @Test + void testFiveTableOmsJoinFindMismatchedStoreId() throws Exception + { + QueryInput queryInput = new QueryInput(); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER, TestUtils.TABLE_NAME_STORE).withAlias("orderStore").withSelect(true)); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER, TestUtils.TABLE_NAME_ORDER_LINE).withSelect(true)); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_LINE, TestUtils.TABLE_NAME_ITEM).withSelect(true)); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ITEM, TestUtils.TABLE_NAME_STORE).withAlias("itemStore").withSelect(true)); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria().withFieldName("orderStore.id").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("item.storeId"))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); + QRecord qRecord = queryOutput.getRecords().get(0); + assertEquals(2, qRecord.getValueInteger("id")); + assertEquals(1, qRecord.getValueInteger("orderStore.id")); + assertEquals(2, qRecord.getValueInteger("itemStore.id")); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // run the same setup, but this time, use the other-field-name as itemStore.id, instead of item.storeId // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + queryInput.setFilter(new QQueryFilter(new QFilterCriteria().withFieldName("orderStore.id").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("itemStore.id"))); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); + qRecord = queryOutput.getRecords().get(0); + assertEquals(2, qRecord.getValueInteger("id")); + assertEquals(1, qRecord.getValueInteger("orderStore.id")); + assertEquals(2, qRecord.getValueInteger("itemStore.id")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOmsQueryByOrderLines() throws Exception + { + AtomicInteger orderLineCount = new AtomicInteger(); + runTestSql("SELECT COUNT(*) from order_line", (rs) -> + { + rs.next(); + orderLineCount.set(rs.getInt(1)); + }); + + QueryInput queryInput = new QueryInput(); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER_LINE); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER).withSelect(true)); + + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(orderLineCount.get(), queryOutput.getRecords().size(), "# of rows found by query"); + assertEquals(3, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("order.id").equals(1)).count()); + assertEquals(1, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("order.id").equals(2)).count()); + assertEquals(1, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("orderId").equals(3)).count()); + assertEquals(2, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("orderId").equals(4)).count()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOmsQueryByPersons() throws Exception + { + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + ///////////////////////////////////////////////////// + // inner join on bill-to person should find 6 rows // + ///////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of(new QueryJoin(TestUtils.TABLE_NAME_PERSON).withJoinMetaData(instance.getJoin("orderJoinBillToPerson")).withSelect(true))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(6, queryOutput.getRecords().size(), "# of rows found by query"); + + ///////////////////////////////////////////////////// + // inner join on ship-to person should find 7 rows // + ///////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of(new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withSelect(true))); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(7, queryOutput.getRecords().size(), "# of rows found by query"); + + ///////////////////////////////////////////////////////////////////////////// + // inner join on both bill-to person and ship-to person should find 5 rows // + ///////////////////////////////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true) + )); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(5, queryOutput.getRecords().size(), "# of rows found by query"); + + ///////////////////////////////////////////////////////////////////////////// + // left join on both bill-to person and ship-to person should find 8 rows // + ///////////////////////////////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withType(QueryJoin.Type.LEFT).withAlias("shipToPerson").withSelect(true), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withType(QueryJoin.Type.LEFT).withAlias("billToPerson").withSelect(true) + )); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(8, queryOutput.getRecords().size(), "# of rows found by query"); + + ////////////////////////////////////////////////// + // now join through to personalIdCard table too // + ////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), + new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), + new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) + )); + queryInput.setFilter(new QQueryFilter() + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // look for billToPersons w/ idNumber starting with 1980 - should only be James and Darin (assert on that below). // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + .withCriteria(new QFilterCriteria("billToIdCard.idNumber", QCriteriaOperator.STARTS_WITH, "1980")) + ); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(3, queryOutput.getRecords().size(), "# of rows found by query"); + assertThat(queryOutput.getRecords().stream().map(r -> r.getValueString("billToPerson.firstName")).toList()).allMatch(p -> p.equals("Darin") || p.equals("James")); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // ensure we throw if either of the ambiguous joins from person to id-card doesn't specify its left-table // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), + new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), + new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) + )); + assertThatThrownBy(() -> new QueryAction().execute(queryInput)) + .rootCause() + .hasMessageContaining("Could not find a join between tables [order][personalIdCard]"); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // ensure we throw if either of the ambiguous joins from person to id-card doesn't specify its left-table // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), + new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), + new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) + )); + assertThatThrownBy(() -> new QueryAction().execute(queryInput)) + .rootCause() + .hasMessageContaining("Could not find a join between tables [order][personalIdCard]"); + + //////////////////////////////////////////////////////////////////////// + // ensure we throw if we have a bogus alias name given as a left-side // + //////////////////////////////////////////////////////////////////////// + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), + new QueryJoin("notATable", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), + new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) + )); + assertThatThrownBy(() -> new QueryAction().execute(queryInput)) + .hasRootCauseMessage("Could not find a join between tables [notATable][personalIdCard]"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testOmsQueryByPersonsExtraKelkhoffOrder() throws Exception + { + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // insert a second person w/ last name Kelkhoff, then an order for Darin Kelkhoff and this new Kelkhoff - // + // then query for orders w/ bill to person & ship to person both lastname = Kelkhoff, but different ids. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + Integer specialOrderId = 1701; + runTestSql("INSERT INTO person (id, first_name, last_name, email) VALUES (6, 'Jimmy', 'Kelkhoff', 'dk@gmail.com')", null); + runTestSql("INSERT INTO `order` (id, store_id, bill_to_person_id, ship_to_person_id) VALUES (" + specialOrderId + ", 1, 1, 6)", null); + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withType(QueryJoin.Type.LEFT).withAlias("shipToPerson").withSelect(true), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withType(QueryJoin.Type.LEFT).withAlias("billToPerson").withSelect(true) + )); + queryInput.setFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.lastName").withOperator(QCriteriaOperator.EQUALS).withOtherFieldName("billToPerson.lastName")) + .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.id").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("billToPerson.id")) + ); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); + assertEquals(specialOrderId, queryOutput.getRecords().get(0).getValueInteger("id")); + + //////////////////////////////////////////////////////////// + // re-run that query using personIds from the order table // + //////////////////////////////////////////////////////////// + queryInput.setFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.lastName").withOperator(QCriteriaOperator.EQUALS).withOtherFieldName("billToPerson.lastName")) + .withCriteria(new QFilterCriteria().withFieldName("order.shipToPersonId").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("order.billToPersonId")) + ); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); + assertEquals(specialOrderId, queryOutput.getRecords().get(0).getValueInteger("id")); + + /////////////////////////////////////////////////////////////////////////////////////////////// + // re-run that query using personIds from the order table, but not specifying the table name // + /////////////////////////////////////////////////////////////////////////////////////////////// + queryInput.setFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.lastName").withOperator(QCriteriaOperator.EQUALS).withOtherFieldName("billToPerson.lastName")) + .withCriteria(new QFilterCriteria().withFieldName("shipToPersonId").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("billToPersonId")) + ); + queryOutput = new QueryAction().execute(queryInput); + assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); + assertEquals(specialOrderId, queryOutput.getRecords().get(0).getValueInteger("id")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDuplicateAliases() + { + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson"), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson"), + new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true), + new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true) // w/o alias, should get exception here - dupe table. + )); + assertThatThrownBy(() -> new QueryAction().execute(queryInput)) + .hasRootCauseMessage("Duplicate table name or alias: personalIdCard"); + + queryInput.withQueryJoins(List.of( + new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson"), + new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson"), + new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToPerson").withSelect(true), // dupe alias, should get exception here + new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToPerson").withSelect(true) + )); + assertThatThrownBy(() -> new QueryAction().execute(queryInput)) + .hasRootCauseMessage("Duplicate table name or alias: shipToPerson"); + } + + + + /******************************************************************************* + ** Given tables: + ** order - orderLine - item + ** with exposedJoin on order to item + ** do a query on order, also selecting item. + *******************************************************************************/ + @Test + void testTwoTableAwayExposedJoin() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + queryInput.withQueryJoins(List.of( + new QueryJoin(TestUtils.TABLE_NAME_ITEM).withType(QueryJoin.Type.INNER).withSelect(true) + )); + + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + List records = queryOutput.getRecords(); + assertThat(records).hasSize(11); // one per line item + assertThat(records).allMatch(r -> r.getValue("id") != null); + assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ITEM + ".description") != null); + } + + + + /******************************************************************************* + ** Given tables: + ** order - orderLine - item + ** with exposedJoin on item to order + ** do a query on item, also selecting order. + ** This is a reverse of the above, to make sure join flipping, etc, is good. + *******************************************************************************/ + @Test + void testTwoTableAwayExposedJoinReversed() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ITEM); + + queryInput.withQueryJoins(List.of( + new QueryJoin(TestUtils.TABLE_NAME_ORDER).withType(QueryJoin.Type.INNER).withSelect(true) + )); + + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + List records = queryOutput.getRecords(); + assertThat(records).hasSize(11); // one per line item + assertThat(records).allMatch(r -> r.getValue("description") != null); + assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ORDER + ".id") != null); + } + + + + /******************************************************************************* + ** Given tables: + ** order - orderLine - item + ** with exposedJoin on order to item + ** do a query on order, also selecting item, and also selecting orderLine... + *******************************************************************************/ + @Test + void testTwoTableAwayExposedJoinAlsoSelectingInBetweenTable() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + queryInput.withQueryJoins(List.of( + new QueryJoin(TestUtils.TABLE_NAME_ORDER_LINE).withType(QueryJoin.Type.INNER).withSelect(true), + new QueryJoin(TestUtils.TABLE_NAME_ITEM).withType(QueryJoin.Type.INNER).withSelect(true) + )); + + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + List records = queryOutput.getRecords(); + assertThat(records).hasSize(11); // one per line item + assertThat(records).allMatch(r -> r.getValue("id") != null); + assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ORDER_LINE + ".quantity") != null); + assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ITEM + ".description") != null); + } + + + + /******************************************************************************* + ** Given tables: + ** order - orderLine - item + ** with exposedJoin on order to item + ** do a query on order, filtered by item + *******************************************************************************/ + @Test + void testTwoTableAwayExposedJoinWhereClauseOnly() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria(TestUtils.TABLE_NAME_ITEM + ".description", QCriteriaOperator.STARTS_WITH, "Q-Mart"))); + + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + List records = queryOutput.getRecords(); + assertThat(records).hasSize(4); + assertThat(records).allMatch(r -> r.getValue("id") != null); + } + + + + /******************************************************************************* + ** Given tables: + ** order - orderLine - item + ** with exposedJoin on order to item + ** do a query on order, filtered by item + *******************************************************************************/ + @Test + void testTwoTableAwayExposedJoinWhereClauseBothJoinTables() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + + QInstance instance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.setFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria(TestUtils.TABLE_NAME_ITEM + ".description", QCriteriaOperator.STARTS_WITH, "Q-Mart")) + .withCriteria(new QFilterCriteria(TestUtils.TABLE_NAME_ORDER_LINE + ".quantity", QCriteriaOperator.IS_NOT_BLANK)) + ); + + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + List records = queryOutput.getRecords(); + assertThat(records).hasSize(4); + assertThat(records).allMatch(r -> r.getValue("id") != null); + } + + + + /******************************************************************************* + ** queries on the store table, where the primary key (id) is the security field + *******************************************************************************/ + @Test + void testRecordSecurityPrimaryKeyFieldNoFilters() throws QException + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_STORE); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(3); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(1) + .anyMatch(r -> r.getValueInteger("id").equals(1)); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(1) + .anyMatch(r -> r.getValueInteger("id").equals(2)); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession()); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList())); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(2) + .anyMatch(r -> r.getValueInteger("id").equals(1)) + .anyMatch(r -> r.getValueInteger("id").equals(3)); + } + + + + /******************************************************************************* + ** not really expected to be any different from where we filter on the primary key, + ** but just good to make sure + *******************************************************************************/ + @Test + void testRecordSecurityForeignKeyFieldNoFilters() throws QException + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(8); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(3) + .allMatch(r -> r.getValueInteger("storeId").equals(1)); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(2) + .allMatch(r -> r.getValueInteger("storeId").equals(2)); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession()); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList())); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(6) + .allMatch(r -> r.getValueInteger("storeId").equals(1) || r.getValueInteger("storeId").equals(3)); + } + + + + /******************************************************************************* + ** Error seen in CTLive - query for order join lineItem, where lineItem's security + ** key is in order. + ** + ** Note - in this test-db setup, there happens to be a storeId in both order & + ** orderLine tables, so we can't quite reproduce the error we saw in CTL - so + ** query on different tables with the structure that'll produce the error. + *******************************************************************************/ + @Test + void testRequestedJoinWithTableWhoseSecurityFieldIsInMainTable() throws QException + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_WAREHOUSE_STORE_INT); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_WAREHOUSE).withSelect(true)); + + ////////////////////////////////////////////// + // with the all-access key, find all 3 rows // + ////////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(3); + + /////////////////////////////////////////// + // with 1 security key value, find 1 row // + /////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(1) + .allMatch(r -> r.getValueInteger("storeId").equals(1)); + + /////////////////////////////////////////// + // with 1 security key value, find 1 row // + /////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(1) + .allMatch(r -> r.getValueInteger("storeId").equals(2)); + + ////////////////////////////////////////////////////////// + // with a mis-matching security key value, 0 rows found // + ////////////////////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + /////////////////////////////////////////////// + // with no security key values, 0 rows found // + /////////////////////////////////////////////// + QContext.setQSession(new QSession()); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + //////////////////////////////////////////////// + // with null security key value, 0 rows found // + //////////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + ////////////////////////////////////////////////////// + // with empty-list security key value, 0 rows found // + ////////////////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList())); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + //////////////////////////////// + // with 2 values, find 2 rows // + //////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(2) + .allMatch(r -> r.getValueInteger("storeId").equals(1) || r.getValueInteger("storeId").equals(3)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRecordSecurityWithFilters() throws QException + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(6); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(2) + .allMatch(r -> r.getValueInteger("storeId").equals(1)); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession()); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("storeId", QCriteriaOperator.IN, List.of(1, 2)))); + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(3) + .allMatch(r -> r.getValueInteger("storeId").equals(1)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRecordSecurityFromJoinTableAlsoImplicitlyInQuery() throws QException + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER_LINE); + + /////////////////////////////////////////////////////////////////////////////////////// + // orders 1, 2, and 3 are from store 1, so their lines (5 in total) should be found. // + // note, order 2 has the line with mis-matched store id - but, that shouldn't apply // + // here, because the line table's security comes from the order table. // + /////////////////////////////////////////////////////////////////////////////////////// + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("order.id", QCriteriaOperator.IN, List.of(1, 2, 3, 4)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(5); + + /////////////////////////////////////////////////////////////////// + // order 4 should be the only one found this time (with 2 lines) // + /////////////////////////////////////////////////////////////////// + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("order.id", QCriteriaOperator.IN, List.of(1, 2, 3, 4)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(2); + + //////////////////////////////////////////////////////////////// + // make sure we're also good if we explicitly join this table // + //////////////////////////////////////////////////////////////// + queryInput.withQueryJoin(new QueryJoin().withJoinTable(TestUtils.TABLE_NAME_ORDER).withSelect(true)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(2); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRecordSecurityWithLockFromJoinTable() throws QException + { + QInstance qInstance = TestUtils.defineInstance(); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // remove the normal lock on the order table - replace it with one from the joined store table // + ///////////////////////////////////////////////////////////////////////////////////////////////// + qInstance.getTable(TestUtils.TABLE_NAME_ORDER).getRecordSecurityLocks().clear(); + qInstance.getTable(TestUtils.TABLE_NAME_ORDER).withRecordSecurityLock(new RecordSecurityLock() + .withSecurityKeyType(TestUtils.TABLE_NAME_STORE) + .withJoinNameChain(List.of("orderJoinStore")) + .withFieldName("store.id")); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(6); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(2) + .allMatch(r -> r.getValueInteger("storeId").equals(1)); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); + QContext.setQSession(new QSession()); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("storeId", QCriteriaOperator.IN, List.of(1, 2)))); + QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(3) + .allMatch(r -> r.getValueInteger("storeId").equals(1)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRecordSecurityWithLockFromJoinTableWhereTheKeyIsOnTheManySide() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_WAREHOUSE); + + assertThat(new QueryAction().execute(queryInput).getRecords()) + .hasSize(1); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testMultipleReversedDirectionJoinsBetweenSameTables() throws QException + { + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); + + Integer noOfOrders = new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER)).getCount(); + Integer noOfOrderInstructions = new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS)).getCount(); + + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure we can join on order.current_order_instruction_id = order_instruction.id -- and that we get back 1 row per order // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS).withJoinMetaData(QContext.getQInstance().getJoin("orderJoinCurrentOrderInstructions"))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(noOfOrders, queryOutput.getRecords().size()); + } + + { + //////////////////////////////////////////////////////////////////////////////////////////////// + // assert that the query succeeds (based on exposed join) if the joinMetaData isn't specified // + //////////////////////////////////////////////////////////////////////////////////////////////// + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS)); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(noOfOrders, queryOutput.getRecords().size()); + } + + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure we can join on order.id = order_instruction.order_id (e.g., not the exposed one used above) -- and that we get back 1 row per order instruction // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS).withJoinMetaData(QContext.getQInstance().getJoin("orderInstructionsJoinOrder"))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertEquals(noOfOrderInstructions, queryOutput.getRecords().size()); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSecurityJoinForJoinedTableFromImplicitlyJoinedTable() throws QException + { + ///////////////////////////////////////////////////////////////////////////////////////// + // in this test: // + // query on Order, joined with OrderLine. // + // Order has its own security field (storeId), that's always worked fine. // + // We want to change OrderLine's security field to be item.storeId - not order.storeId // + // so that item has to be brought into the query to secure the items. // + // this was originally broken, as it would generate a WHERE clause for item.storeId, // + // but it wouldn't put item in the FROM cluase. + ///////////////////////////////////////////////////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); + QContext.getQInstance().getTable(TestUtils.TABLE_NAME_ORDER_LINE) + .setRecordSecurityLocks(ListBuilder.of( + new RecordSecurityLock() + .withSecurityKeyType(TestUtils.TABLE_NAME_STORE) + .withFieldName("item.storeId") + .withJoinNameChain(List.of("orderLineJoinItem")))); + + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_LINE).withSelect(true)); + queryInput.withFilter(new QQueryFilter(new QFilterCriteria(TestUtils.TABLE_NAME_ORDER_LINE + ".sku", QCriteriaOperator.IS_NOT_BLANK))); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + List records = queryOutput.getRecords(); + assertEquals(3, records.size(), "expected no of records"); + + /////////////////////////////////////////////////////////////////////// + // we should get the orderLines for orders 4 and 5 - but not the one // + // for order 2, as it has an item from a different store // + /////////////////////////////////////////////////////////////////////// + assertThat(records).allMatch(r -> r.getValueInteger("id").equals(4) || r.getValueInteger("id").equals(5)); + } + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java index 01c2c65c..249c5495 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionTest.java @@ -25,26 +25,20 @@ package com.kingsrook.qqq.backend.module.rdbms.actions; import java.io.Serializable; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; -import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; 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.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.Now; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset; @@ -52,14 +46,12 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.module.rdbms.TestUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -783,675 +775,6 @@ public class RDBMSQueryActionTest extends RDBMSActionTest - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testFilterFromJoinTableImplicitly() throws QException - { - QueryInput queryInput = initQueryRequest(); - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("personalIdCard.idNumber", QCriteriaOperator.EQUALS, "19800531"))); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(1, queryOutput.getRecords().size(), "Query should find 1 rows"); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin")); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOneToOneInnerJoinWithoutWhere() throws QException - { - QueryInput queryInput = initQueryRequest(); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true)); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(3, queryOutput.getRecords().size(), "Join query should find 3 rows"); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tim") && r.getValueString("personalIdCard.idNumber").equals("19760528")); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOneToOneLeftJoinWithoutWhere() throws QException - { - QueryInput queryInput = initQueryRequest(); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withType(QueryJoin.Type.LEFT).withSelect(true)); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(5, queryOutput.getRecords().size(), "Left Join query should find 5 rows"); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tim") && r.getValueString("personalIdCard.idNumber").equals("19760528")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Garret") && r.getValue("personalIdCard.idNumber") == null); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tyler") && r.getValue("personalIdCard.idNumber") == null); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOneToOneRightJoinWithoutWhere() throws QException - { - QueryInput queryInput = initQueryRequest(); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withType(QueryJoin.Type.RIGHT).withSelect(true)); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(6, queryOutput.getRecords().size(), "Right Join query should find 6 rows"); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Tim") && r.getValueString("personalIdCard.idNumber").equals("19760528")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValue("firstName") == null && r.getValueString("personalIdCard.idNumber").equals("123123123")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValue("firstName") == null && r.getValueString("personalIdCard.idNumber").equals("987987987")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValue("firstName") == null && r.getValueString("personalIdCard.idNumber").equals("456456456")); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOneToOneInnerJoinWithWhere() throws QException - { - QueryInput queryInput = initQueryRequest(); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true)); - queryInput.setFilter(new QQueryFilter(new QFilterCriteria(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber", QCriteriaOperator.STARTS_WITH, "1980"))); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(2, queryOutput.getRecords().size(), "Join query should find 2 rows"); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("Darin") && r.getValueString("personalIdCard.idNumber").equals("19800531")); - assertThat(queryOutput.getRecords()).anyMatch(r -> r.getValueString("firstName").equals("James") && r.getValueString("personalIdCard.idNumber").equals("19800515")); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOneToOneInnerJoinWithOrderBy() throws QException - { - QInstance qInstance = TestUtils.defineInstance(); - QueryInput queryInput = initQueryRequest(); - queryInput.withQueryJoin(new QueryJoin(qInstance.getJoin(TestUtils.TABLE_NAME_PERSON + "Join" + StringUtils.ucFirst(TestUtils.TABLE_NAME_PERSONAL_ID_CARD))).withSelect(true)); - queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber"))); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(3, queryOutput.getRecords().size(), "Join query should find 3 rows"); - List idNumberListFromQuery = queryOutput.getRecords().stream().map(r -> r.getValueString(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")).toList(); - assertEquals(List.of("19760528", "19800515", "19800531"), idNumberListFromQuery); - - ///////////////////////// - // repeat, sorted desc // - ///////////////////////// - queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber", false))); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(3, queryOutput.getRecords().size(), "Join query should find 3 rows"); - idNumberListFromQuery = queryOutput.getRecords().stream().map(r -> r.getValueString(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")).toList(); - assertEquals(List.of("19800531", "19800515", "19760528"), idNumberListFromQuery); - } - - - - /******************************************************************************* - ** In the prime data, we've got 1 order line set up with an item from a different - ** store than its order. Write a query to find such a case. - *******************************************************************************/ - @Test - void testFiveTableOmsJoinFindMismatchedStoreId() throws Exception - { - QueryInput queryInput = new QueryInput(); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER, TestUtils.TABLE_NAME_STORE).withAlias("orderStore").withSelect(true)); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER, TestUtils.TABLE_NAME_ORDER_LINE).withSelect(true)); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_LINE, TestUtils.TABLE_NAME_ITEM).withSelect(true)); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ITEM, TestUtils.TABLE_NAME_STORE).withAlias("itemStore").withSelect(true)); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria().withFieldName("orderStore.id").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("item.storeId"))); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); - QRecord qRecord = queryOutput.getRecords().get(0); - assertEquals(2, qRecord.getValueInteger("id")); - assertEquals(1, qRecord.getValueInteger("orderStore.id")); - assertEquals(2, qRecord.getValueInteger("itemStore.id")); - - ////////////////////////////////////////////////////////////////////////////////////////////////////////// - // run the same setup, but this time, use the other-field-name as itemStore.id, instead of item.storeId // - ////////////////////////////////////////////////////////////////////////////////////////////////////////// - queryInput.setFilter(new QQueryFilter(new QFilterCriteria().withFieldName("orderStore.id").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("itemStore.id"))); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); - qRecord = queryOutput.getRecords().get(0); - assertEquals(2, qRecord.getValueInteger("id")); - assertEquals(1, qRecord.getValueInteger("orderStore.id")); - assertEquals(2, qRecord.getValueInteger("itemStore.id")); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOmsQueryByOrderLines() throws Exception - { - AtomicInteger orderLineCount = new AtomicInteger(); - runTestSql("SELECT COUNT(*) from order_line", (rs) -> - { - rs.next(); - orderLineCount.set(rs.getInt(1)); - }); - - QueryInput queryInput = new QueryInput(); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER_LINE); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER).withSelect(true)); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(orderLineCount.get(), queryOutput.getRecords().size(), "# of rows found by query"); - assertEquals(3, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("order.id").equals(1)).count()); - assertEquals(1, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("order.id").equals(2)).count()); - assertEquals(1, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("orderId").equals(3)).count()); - assertEquals(2, queryOutput.getRecords().stream().filter(r -> r.getValueInteger("orderId").equals(4)).count()); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOmsQueryByPersons() throws Exception - { - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - ///////////////////////////////////////////////////// - // inner join on bill-to person should find 6 rows // - ///////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of(new QueryJoin(TestUtils.TABLE_NAME_PERSON).withJoinMetaData(instance.getJoin("orderJoinBillToPerson")).withSelect(true))); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(6, queryOutput.getRecords().size(), "# of rows found by query"); - - ///////////////////////////////////////////////////// - // inner join on ship-to person should find 7 rows // - ///////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of(new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withSelect(true))); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(7, queryOutput.getRecords().size(), "# of rows found by query"); - - ///////////////////////////////////////////////////////////////////////////// - // inner join on both bill-to person and ship-to person should find 5 rows // - ///////////////////////////////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true) - )); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(5, queryOutput.getRecords().size(), "# of rows found by query"); - - ///////////////////////////////////////////////////////////////////////////// - // left join on both bill-to person and ship-to person should find 8 rows // - ///////////////////////////////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withType(QueryJoin.Type.LEFT).withAlias("shipToPerson").withSelect(true), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withType(QueryJoin.Type.LEFT).withAlias("billToPerson").withSelect(true) - )); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(8, queryOutput.getRecords().size(), "# of rows found by query"); - - ////////////////////////////////////////////////// - // now join through to personalIdCard table too // - ////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), - new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), - new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) - )); - queryInput.setFilter(new QQueryFilter() - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // look for billToPersons w/ idNumber starting with 1980 - should only be James and Darin (assert on that below). // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - .withCriteria(new QFilterCriteria("billToIdCard.idNumber", QCriteriaOperator.STARTS_WITH, "1980")) - ); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(3, queryOutput.getRecords().size(), "# of rows found by query"); - assertThat(queryOutput.getRecords().stream().map(r -> r.getValueString("billToPerson.firstName")).toList()).allMatch(p -> p.equals("Darin") || p.equals("James")); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // ensure we throw if either of the ambiguous joins from person to id-card doesn't specify its left-table // - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), - new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), - new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) - )); - assertThatThrownBy(() -> new QueryAction().execute(queryInput)) - .rootCause() - .hasMessageContaining("Could not find a join between tables [order][personalIdCard]"); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // ensure we throw if either of the ambiguous joins from person to id-card doesn't specify its left-table // - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), - new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), - new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) - )); - assertThatThrownBy(() -> new QueryAction().execute(queryInput)) - .rootCause() - .hasMessageContaining("Could not find a join between tables [order][personalIdCard]"); - - //////////////////////////////////////////////////////////////////////// - // ensure we throw if we have a bogus alias name given as a left-side // - //////////////////////////////////////////////////////////////////////// - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson").withSelect(true), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson").withSelect(true), - new QueryJoin("notATable", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToIdCard").withSelect(true), - new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToIdCard").withSelect(true) - )); - assertThatThrownBy(() -> new QueryAction().execute(queryInput)) - .hasRootCauseMessage("Could not find a join between tables [notATable][personalIdCard]"); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testOmsQueryByPersonsExtraKelkhoffOrder() throws Exception - { - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // insert a second person w/ last name Kelkhoff, then an order for Darin Kelkhoff and this new Kelkhoff - // - // then query for orders w/ bill to person & ship to person both lastname = Kelkhoff, but different ids. // - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - Integer specialOrderId = 1701; - runTestSql("INSERT INTO person (id, first_name, last_name, email) VALUES (6, 'Jimmy', 'Kelkhoff', 'dk@gmail.com')", null); - runTestSql("INSERT INTO `order` (id, store_id, bill_to_person_id, ship_to_person_id) VALUES (" + specialOrderId + ", 1, 1, 6)", null); - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withType(QueryJoin.Type.LEFT).withAlias("shipToPerson").withSelect(true), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withType(QueryJoin.Type.LEFT).withAlias("billToPerson").withSelect(true) - )); - queryInput.setFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.lastName").withOperator(QCriteriaOperator.EQUALS).withOtherFieldName("billToPerson.lastName")) - .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.id").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("billToPerson.id")) - ); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); - assertEquals(specialOrderId, queryOutput.getRecords().get(0).getValueInteger("id")); - - //////////////////////////////////////////////////////////// - // re-run that query using personIds from the order table // - //////////////////////////////////////////////////////////// - queryInput.setFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.lastName").withOperator(QCriteriaOperator.EQUALS).withOtherFieldName("billToPerson.lastName")) - .withCriteria(new QFilterCriteria().withFieldName("order.shipToPersonId").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("order.billToPersonId")) - ); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); - assertEquals(specialOrderId, queryOutput.getRecords().get(0).getValueInteger("id")); - - /////////////////////////////////////////////////////////////////////////////////////////////// - // re-run that query using personIds from the order table, but not specifying the table name // - /////////////////////////////////////////////////////////////////////////////////////////////// - queryInput.setFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria().withFieldName("shipToPerson.lastName").withOperator(QCriteriaOperator.EQUALS).withOtherFieldName("billToPerson.lastName")) - .withCriteria(new QFilterCriteria().withFieldName("shipToPersonId").withOperator(QCriteriaOperator.NOT_EQUALS).withOtherFieldName("billToPersonId")) - ); - queryOutput = new QueryAction().execute(queryInput); - assertEquals(1, queryOutput.getRecords().size(), "# of rows found by query"); - assertEquals(specialOrderId, queryOutput.getRecords().get(0).getValueInteger("id")); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testDuplicateAliases() - { - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson"), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson"), - new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true), - new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true) // w/o alias, should get exception here - dupe table. - )); - assertThatThrownBy(() -> new QueryAction().execute(queryInput)) - .hasRootCauseMessage("Duplicate table name or alias: personalIdCard"); - - queryInput.withQueryJoins(List.of( - new QueryJoin(instance.getJoin("orderJoinShipToPerson")).withAlias("shipToPerson"), - new QueryJoin(instance.getJoin("orderJoinBillToPerson")).withAlias("billToPerson"), - new QueryJoin("shipToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("shipToPerson").withSelect(true), // dupe alias, should get exception here - new QueryJoin("billToPerson", TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("billToPerson").withSelect(true) - )); - assertThatThrownBy(() -> new QueryAction().execute(queryInput)) - .hasRootCauseMessage("Duplicate table name or alias: shipToPerson"); - } - - - - /******************************************************************************* - ** Given tables: - ** order - orderLine - item - ** with exposedJoin on order to item - ** do a query on order, also selecting item. - *******************************************************************************/ - @Test - void testTwoTableAwayExposedJoin() throws QException - { - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - queryInput.withQueryJoins(List.of( - new QueryJoin(TestUtils.TABLE_NAME_ITEM).withType(QueryJoin.Type.INNER).withSelect(true) - )); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - List records = queryOutput.getRecords(); - assertThat(records).hasSize(11); // one per line item - assertThat(records).allMatch(r -> r.getValue("id") != null); - assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ITEM + ".description") != null); - } - - - - /******************************************************************************* - ** Given tables: - ** order - orderLine - item - ** with exposedJoin on item to order - ** do a query on item, also selecting order. - ** This is a reverse of the above, to make sure join flipping, etc, is good. - *******************************************************************************/ - @Test - void testTwoTableAwayExposedJoinReversed() throws QException - { - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ITEM); - - queryInput.withQueryJoins(List.of( - new QueryJoin(TestUtils.TABLE_NAME_ORDER).withType(QueryJoin.Type.INNER).withSelect(true) - )); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - List records = queryOutput.getRecords(); - assertThat(records).hasSize(11); // one per line item - assertThat(records).allMatch(r -> r.getValue("description") != null); - assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ORDER + ".id") != null); - } - - - - /******************************************************************************* - ** Given tables: - ** order - orderLine - item - ** with exposedJoin on order to item - ** do a query on order, also selecting item, and also selecting orderLine... - *******************************************************************************/ - @Test - void testTwoTableAwayExposedJoinAlsoSelectingInBetweenTable() throws QException - { - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - queryInput.withQueryJoins(List.of( - new QueryJoin(TestUtils.TABLE_NAME_ORDER_LINE).withType(QueryJoin.Type.INNER).withSelect(true), - new QueryJoin(TestUtils.TABLE_NAME_ITEM).withType(QueryJoin.Type.INNER).withSelect(true) - )); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - List records = queryOutput.getRecords(); - assertThat(records).hasSize(11); // one per line item - assertThat(records).allMatch(r -> r.getValue("id") != null); - assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ORDER_LINE + ".quantity") != null); - assertThat(records).allMatch(r -> r.getValue(TestUtils.TABLE_NAME_ITEM + ".description") != null); - } - - - - /******************************************************************************* - ** Given tables: - ** order - orderLine - item - ** with exposedJoin on order to item - ** do a query on order, filtered by item - *******************************************************************************/ - @Test - void testTwoTableAwayExposedJoinWhereClauseOnly() throws QException - { - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - queryInput.setFilter(new QQueryFilter(new QFilterCriteria(TestUtils.TABLE_NAME_ITEM + ".description", QCriteriaOperator.STARTS_WITH, "Q-Mart"))); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - List records = queryOutput.getRecords(); - assertThat(records).hasSize(4); - assertThat(records).allMatch(r -> r.getValue("id") != null); - } - - - - /******************************************************************************* - ** Given tables: - ** order - orderLine - item - ** with exposedJoin on order to item - ** do a query on order, filtered by item - *******************************************************************************/ - @Test - void testTwoTableAwayExposedJoinWhereClauseBothJoinTables() throws QException - { - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - - QInstance instance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - queryInput.setFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria(TestUtils.TABLE_NAME_ITEM + ".description", QCriteriaOperator.STARTS_WITH, "Q-Mart")) - .withCriteria(new QFilterCriteria(TestUtils.TABLE_NAME_ORDER_LINE + ".quantity", QCriteriaOperator.IS_NOT_BLANK)) - ); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - List records = queryOutput.getRecords(); - assertThat(records).hasSize(4); - assertThat(records).allMatch(r -> r.getValue("id") != null); - } - - - - /******************************************************************************* - ** queries on the store table, where the primary key (id) is the security field - *******************************************************************************/ - @Test - void testRecordSecurityPrimaryKeyFieldNoFilters() throws QException - { - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_STORE); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(3); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(1) - .anyMatch(r -> r.getValueInteger("id").equals(1)); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(1) - .anyMatch(r -> r.getValueInteger("id").equals(2)); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession()); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList())); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(2) - .anyMatch(r -> r.getValueInteger("id").equals(1)) - .anyMatch(r -> r.getValueInteger("id").equals(3)); - } - - - - /******************************************************************************* - ** not really expected to be any different from where we filter on the primary key, - ** but just good to make sure - *******************************************************************************/ - @Test - void testRecordSecurityForeignKeyFieldNoFilters() throws QException - { - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(8); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(3) - .allMatch(r -> r.getValueInteger("storeId").equals(1)); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(2) - .allMatch(r -> r.getValueInteger("storeId").equals(2)); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession()); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, null)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, Collections.emptyList())); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(6) - .allMatch(r -> r.getValueInteger("storeId").equals(1) || r.getValueInteger("storeId").equals(3)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testRecordSecurityWithFilters() throws QException - { - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(6); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(2) - .allMatch(r -> r.getValueInteger("storeId").equals(1)); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession()); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("storeId", QCriteriaOperator.IN, List.of(1, 2)))); - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(3) - .allMatch(r -> r.getValueInteger("storeId").equals(1)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testRecordSecurityFromJoinTableAlsoImplicitlyInQuery() throws QException - { - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER_LINE); - - /////////////////////////////////////////////////////////////////////////////////////////// - // orders 1, 2, and 3 are from store 1, so their lines (5 in total) should be found. // - // note, order 2 has the line with mis-matched store id - but, that shouldn't apply here // - /////////////////////////////////////////////////////////////////////////////////////////// - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("order.id", QCriteriaOperator.IN, List.of(1, 2, 3, 4)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); - assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(5); - - /////////////////////////////////////////////////////////////////// - // order 4 should be the only one found this time (with 2 lines) // - /////////////////////////////////////////////////////////////////// - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("order.id", QCriteriaOperator.IN, List.of(1, 2, 3, 4)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 2)); - assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(2); - - //////////////////////////////////////////////////////////////// - // make sure we're also good if we explicitly join this table // - //////////////////////////////////////////////////////////////// - queryInput.withQueryJoin(new QueryJoin().withJoinTable(TestUtils.TABLE_NAME_ORDER).withSelect(true)); - assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(2); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -1606,68 +929,6 @@ public class RDBMSQueryActionTest extends RDBMSActionTest - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testRecordSecurityWithLockFromJoinTable() throws QException - { - QInstance qInstance = TestUtils.defineInstance(); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - - ///////////////////////////////////////////////////////////////////////////////////////////////// - // remove the normal lock on the order table - replace it with one from the joined store table // - ///////////////////////////////////////////////////////////////////////////////////////////////// - qInstance.getTable(TestUtils.TABLE_NAME_ORDER).getRecordSecurityLocks().clear(); - qInstance.getTable(TestUtils.TABLE_NAME_ORDER).withRecordSecurityLock(new RecordSecurityLock() - .withSecurityKeyType(TestUtils.TABLE_NAME_STORE) - .withJoinNameChain(List.of("orderJoinStore")) - .withFieldName("store.id")); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - assertThat(new QueryAction().execute(queryInput).getRecords()).hasSize(6); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(2) - .allMatch(r -> r.getValueInteger("storeId").equals(1)); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 5)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.BETWEEN, List.of(2, 7)))); - QContext.setQSession(new QSession()); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - queryInput.setFilter(new QQueryFilter(new QFilterCriteria("storeId", QCriteriaOperator.IN, List.of(1, 2)))); - QContext.setQSession(new QSession().withSecurityKeyValues(TestUtils.TABLE_NAME_STORE, List.of(1, 3))); - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(3) - .allMatch(r -> r.getValueInteger("storeId").equals(1)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testRecordSecurityWithLockFromJoinTableWhereTheKeyIsOnTheManySide() throws QException - { - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_WAREHOUSE); - - assertThat(new QueryAction().execute(queryInput).getRecords()) - .hasSize(1); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -1697,51 +958,4 @@ public class RDBMSQueryActionTest extends RDBMSActionTest } - - - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testMultipleReversedDirectionJoinsBetweenSameTables() throws QException - { - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_STORE_ALL_ACCESS, true)); - - { - ///////////////////////////////////////////////////////// - // assert a failure if the join to use isn't specified // - ///////////////////////////////////////////////////////// - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS)); - assertThatThrownBy(() -> new QueryAction().execute(queryInput)).rootCause().hasMessageContaining("More than 1 join was found"); - } - - Integer noOfOrders = new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER)).getCount(); - Integer noOfOrderInstructions = new CountAction().execute(new CountInput(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS)).getCount(); - - { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // make sure we can join on order.current_order_instruction_id = order_instruction.id -- and that we get back 1 row per order // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS).withJoinMetaData(QContext.getQInstance().getJoin("orderJoinCurrentOrderInstructions"))); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(noOfOrders, queryOutput.getRecords().size()); - } - - { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // make sure we can join on order.id = order_instruction.order_id -- and that we get back 1 row per order instruction // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(TestUtils.TABLE_NAME_ORDER); - queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ORDER_INSTRUCTIONS).withJoinMetaData(QContext.getQInstance().getJoin("orderInstructionsJoinOrder"))); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - assertEquals(noOfOrderInstructions, queryOutput.getRecords().size()); - } - - } - }