From 73e826f81db747801876aa13ec570df23cf4896e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 7 Sep 2023 12:23:12 -0500 Subject: [PATCH 01/58] 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()); - } - - } - } From 6c7621a2f7888dd90eaba24df551495f7bf43da7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 8 Sep 2023 10:30:39 -0500 Subject: [PATCH 02/58] Update handling of criteria in format "table.field" when the "table" portion equals the record's tableName; fix applyBooleanOperator to always update the accumulator; --- .../utils/BackendQueryFilterUtils.java | 12 +- .../utils/BackendQueryFilterUtilsTest.java | 271 ++++++++++++++++++ 2 files changed, 279 insertions(+), 4 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java index 06d36f64..5356ebe6 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java @@ -75,14 +75,16 @@ public class BackendQueryFilterUtils { /////////////////////////////////////////////////////////////////////////////////////////////////// // if the value isn't in the record - check, if it looks like a table.fieldName, but none of the // - // field names in the record are fully qualified, then just use the field-name portion... // + // field names in the record are fully qualified - OR - the table name portion of the field name // + // matches the record's field name, then just use the field-name portion... // /////////////////////////////////////////////////////////////////////////////////////////////////// if(fieldName.contains(".")) { + String[] parts = fieldName.split("\\."); Map values = qRecord.getValues(); - if(values.keySet().stream().noneMatch(n -> n.contains("."))) + if(values.keySet().stream().noneMatch(n -> n.contains(".")) || parts[0].equals(qRecord.getTableName())) { - value = qRecord.getValue(fieldName.substring(fieldName.indexOf(".") + 1)); + value = qRecord.getValue(parts[1]); } } } @@ -190,12 +192,13 @@ public class BackendQueryFilterUtils ** operator, update the accumulator, and if we can then short-circuit remaining ** operations, return a true or false. Returning null means to keep going. *******************************************************************************/ - private static Boolean applyBooleanOperator(AtomicBoolean accumulator, boolean newValue, QQueryFilter.BooleanOperator booleanOperator) + static Boolean applyBooleanOperator(AtomicBoolean accumulator, boolean newValue, QQueryFilter.BooleanOperator booleanOperator) { boolean accumulatorValue = accumulator.getPlain(); if(booleanOperator.equals(QQueryFilter.BooleanOperator.AND)) { accumulatorValue &= newValue; + accumulator.set(accumulatorValue); if(!accumulatorValue) { return (false); @@ -204,6 +207,7 @@ public class BackendQueryFilterUtils else { accumulatorValue |= newValue; + accumulator.set(accumulatorValue); if(accumulatorValue) { return (true); diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java index 677fb8d8..afc3ad1f 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtilsTest.java @@ -23,11 +23,16 @@ package com.kingsrook.qqq.backend.core.modules.backend.implementations.utils; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -37,6 +42,182 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class BackendQueryFilterUtilsTest { + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDoesRecordMatch_emptyFilters() + { + assertTrue(BackendQueryFilterUtils.doesRecordMatch(null, new QRecord().withValue("a", 1))); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(new QQueryFilter(), new QRecord().withValue("a", 1))); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(new QQueryFilter().withSubFilters(ListBuilder.of(null)), new QRecord().withValue("a", 1))); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(new QQueryFilter().withSubFilters(List.of(new QQueryFilter())), new QRecord().withValue("a", 1))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDoesRecordMatch_singleAnd() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withCriteria(new QFilterCriteria("a", QCriteriaOperator.EQUALS, 1)); + + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 2))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDoesRecordMatch_singleOr() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withCriteria(new QFilterCriteria("a", QCriteriaOperator.EQUALS, 1)); + + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 2))); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Test + void testDoesRecordMatch_multipleAnd() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withCriteria(new QFilterCriteria("a", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("b", QCriteriaOperator.EQUALS, 2)); + + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 2))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 2).withValue("b", 2))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord())); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Test + void testDoesRecordMatch_multipleOr() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withCriteria(new QFilterCriteria("a", QCriteriaOperator.EQUALS, 1)) + .withCriteria(new QFilterCriteria("b", QCriteriaOperator.EQUALS, 2)); + + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 2))); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 2).withValue("b", 2))); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 3).withValue("b", 4))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord())); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Test + void testDoesRecordMatch_subFilterAnd() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withSubFilters(List.of( + new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withCriteria(new QFilterCriteria("a", QCriteriaOperator.EQUALS, 1)), + new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withCriteria(new QFilterCriteria("b", QCriteriaOperator.EQUALS, 2)) + )); + + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 2))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 2).withValue("b", 2))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord())); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Test + void testDoesRecordMatch_subFilterOr() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withSubFilters(List.of( + new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withCriteria(new QFilterCriteria("a", QCriteriaOperator.EQUALS, 1)), + new QQueryFilter() + .withCriteria(new QFilterCriteria("b", QCriteriaOperator.EQUALS, 2)) + )); + + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 2))); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 2).withValue("b", 2))); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("b", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 3).withValue("b", 4))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDoesRecordMatch_criteriaHasTableNameNoFieldsDo() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withCriteria(new QFilterCriteria("t.a", QCriteriaOperator.EQUALS, 1)); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDoesRecordMatch_criteriaHasTableNameSomeFieldsDo() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withCriteria(new QFilterCriteria("t.a", QCriteriaOperator.EQUALS, 1)); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // shouldn't find the "a", because "some" fields in here have a prefix (e.g., 's' was a join table, selected with 't' as the main table, which didn't prefix) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("a", 1).withValue("s.b", 2))); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // but this case (contrasted with above) set the record's tableName to "t", so criteria on "t.a" should find field "a" // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withTableName("t").withValue("a", 1).withValue("s.b", 2))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDoesRecordMatch_criteriaHasTableNameMatchingField() + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.AND) + .withCriteria(new QFilterCriteria("t.a", QCriteriaOperator.EQUALS, 1)); + assertTrue(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("t.a", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("t.b", 1))); + assertFalse(BackendQueryFilterUtils.doesRecordMatch(filter, new QRecord().withValue("s.a", 1))); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -184,4 +365,94 @@ class BackendQueryFilterUtilsTest assertFalse("Not Darin".matches(pattern)); assertFalse("David".matches(pattern)); } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testApplyBooleanOperator() + { + ///////////////////////////// + // tests for operator: AND // + ///////////////////////////// + { + ///////////////////////////////////////////////////////////////////////////////////// + // old value was true; new value is true. // + // result should be true, and we should not be short-circuited (return value null) // + ///////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(true); + assertNull(BackendQueryFilterUtils.applyBooleanOperator(accumulator, true, QQueryFilter.BooleanOperator.AND)); + assertTrue(accumulator.getPlain()); + } + { + ////////////////////////////////////////////////////////////////////////////////////// + // old value was true; new value is false. // + // result should be false, and we should be short-circuited (return value not-null) // + ////////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(true); + assertEquals(Boolean.FALSE, BackendQueryFilterUtils.applyBooleanOperator(accumulator, false, QQueryFilter.BooleanOperator.AND)); + assertFalse(accumulator.getPlain()); + } + { + ////////////////////////////////////////////////////////////////////////////////////// + // old value was false; new value is true. // + // result should be false, and we should be short-circuited (return value not-null) // + ////////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(false); + assertEquals(Boolean.FALSE, BackendQueryFilterUtils.applyBooleanOperator(accumulator, true, QQueryFilter.BooleanOperator.AND)); + assertFalse(accumulator.getPlain()); + } + { + ////////////////////////////////////////////////////////////////////////////////////// + // old value was false; new value is false. // + // result should be false, and we should be short-circuited (return value not-null) // + ////////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(false); + assertEquals(Boolean.FALSE, BackendQueryFilterUtils.applyBooleanOperator(accumulator, false, QQueryFilter.BooleanOperator.AND)); + assertFalse(accumulator.getPlain()); + } + + //////////////////////////// + // tests for operator: OR // + //////////////////////////// + { + ///////////////////////////////////////////////////////////////////////////////////// + // old value was true; new value is true. // + // result should be true, and we should be short-circuited (return value not-null) // + ///////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(true); + assertEquals(Boolean.TRUE, BackendQueryFilterUtils.applyBooleanOperator(accumulator, true, QQueryFilter.BooleanOperator.OR)); + assertTrue(accumulator.getPlain()); + } + { + ////////////////////////////////////////////////////////////////////////////////////// + // old value was true; new value is false. // + // result should be true, and we should be short-circuited (return value not-null) // + ////////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(true); + assertEquals(Boolean.TRUE, BackendQueryFilterUtils.applyBooleanOperator(accumulator, false, QQueryFilter.BooleanOperator.OR)); + assertTrue(accumulator.getPlain()); + } + { + ////////////////////////////////////////////////////////////////////////////////////// + // old value was false; new value is true. // + // result should be false, and we should be short-circuited (return value not-null) // + ////////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(false); + assertEquals(Boolean.TRUE, BackendQueryFilterUtils.applyBooleanOperator(accumulator, true, QQueryFilter.BooleanOperator.OR)); + assertTrue(accumulator.getPlain()); + } + { + ////////////////////////////////////////////////////////////////////////////////////// + // old value was false; new value is false. // + // result should be false, and we should not be short-circuited (return value null) // + ////////////////////////////////////////////////////////////////////////////////////// + AtomicBoolean accumulator = new AtomicBoolean(false); + assertNull(BackendQueryFilterUtils.applyBooleanOperator(accumulator, false, QQueryFilter.BooleanOperator.OR)); + assertFalse(accumulator.getPlain()); + } + } + } \ No newline at end of file From a85c06a407085684062be65bf417a4914af4dd78 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 8 Sep 2023 10:32:58 -0500 Subject: [PATCH 03/58] Set tableName if null before filtering (as BackendQueryFilterUtils uses it for some cases now) --- .../backend/implementations/memory/MemoryRecordStore.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 009a6981..c7fdcc03 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 @@ -171,6 +171,14 @@ public class MemoryRecordStore for(QRecord qRecord : tableData) { + if(qRecord.getTableName() == null) + { + /////////////////////////////////////////////////////////////////////////////////////////// + // internally, doesRecordMatch likes to know table names on records, so, set if missing. // + /////////////////////////////////////////////////////////////////////////////////////////// + qRecord.setTableName(input.getTableName()); + } + boolean recordMatches = BackendQueryFilterUtils.doesRecordMatch(input.getFilter(), qRecord); if(recordMatches) From 831ac3bc07a640ac2a0a101945bc5eb62253c850 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 8 Sep 2023 10:33:20 -0500 Subject: [PATCH 04/58] Avoid NPE in hasAnyCriteria if a sub-filter in the list is null --- .../backend/core/model/actions/tables/query/QQueryFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 36933402..18746cd6 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 @@ -149,7 +149,7 @@ public class QQueryFilter implements Serializable, Cloneable { for(QQueryFilter subFilter : subFilters) { - if(subFilter.hasAnyCriteria()) + if(subFilter != null && subFilter.hasAnyCriteria()) { return (true); } From 635807c525df945fd04225eb8d383519492c6eeb Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Sep 2023 11:09:47 -0500 Subject: [PATCH 05/58] Avoid NPE in toString if orderBys is null --- .../backend/core/model/actions/tables/query/QQueryFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 18746cd6..231d6864 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 @@ -359,7 +359,7 @@ public class QQueryFilter implements Serializable, Cloneable rs.append(")"); rs.append("OrderBy["); - for(QFilterOrderBy orderBy : orderBys) + for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys)) { rs.append(orderBy).append(","); } From 34a1cd80f4ca1aef589e171fb613efcfca59d93c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Sep 2023 13:54:05 -0500 Subject: [PATCH 06/58] in getSqlWhereStringAndPopulateParamsList... - skip a criteria with null fieldName or operator - and then if there were no valid criteria, return 1=1 --- .../rdbms/actions/AbstractRDBMSAction.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 7394cbfd..1bf0b4fd 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 @@ -399,6 +399,18 @@ public abstract class AbstractRDBMSAction implements QActionInterface List clauses = new ArrayList<>(); for(QFilterCriteria criterion : criteria) { + if(criterion.getFieldName() == null) + { + LOG.info("QFilter criteria is missing a fieldName - will not be included in query."); + continue; + } + + if(criterion.getOperator() == null) + { + LOG.info("QFilter criteria is missing a operator - will not be included in query.", logPair("fieldName", criterion.getFieldName())); + continue; + } + JoinsContext.FieldAndTableNameOrAlias fieldAndTableNameOrAlias = joinsContext.getFieldAndTableNameOrAlias(criterion.getFieldName()); List values = criterion.getValues() == null ? new ArrayList<>() : new ArrayList<>(criterion.getValues()); @@ -628,6 +640,16 @@ public abstract class AbstractRDBMSAction implements QActionInterface params.addAll(values); } + ////////////////////////////////////////////////////////////////////////////// + // since we're skipping criteria w/o a field or operator in the loop - // + // we can get to the end here without any clauses... so, return a 1=1 then, // + // as whoever called this is probably already written a WHERE or AND // + ////////////////////////////////////////////////////////////////////////////// + if(clauses.isEmpty()) + { + return ("1 = 1"); + } + return (String.join(" " + booleanOperator.toString() + " ", clauses)); } From 02cd335b95ff8d00802976a3623f7c3346dbb3fb Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Sep 2023 13:54:35 -0500 Subject: [PATCH 07/58] Only consider read-locks when looking at join tables --- .../backend/core/model/actions/tables/query/JoinsContext.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 70aa2ccb..986cab7e 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 @@ -209,7 +209,7 @@ public class JoinsContext // 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())) + for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks()))) { List addedQueryJoins = ensureRecordSecurityLockIsRepresented(joinTable.getName(), queryJoin.getJoinTableOrItsAlias(), recordSecurityLock, queryJoin); From f0d59895f072afe254140184a2e3ea53888e8b6f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Sep 2023 13:55:06 -0500 Subject: [PATCH 08/58] Avoid NPE (in StringBuilder constructor?) if fieldName is null. --- .../core/model/actions/tables/query/QFilterCriteria.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java index 0072b6c9..2274cc55 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QFilterCriteria.java @@ -278,6 +278,11 @@ public class QFilterCriteria implements Serializable, Cloneable @Override public String toString() { + if(fieldName == null) + { + return (""); + } + StringBuilder rs = new StringBuilder(fieldName); try { From c3d69d812a98b7503d73f1348e3cb50f5bd7c0cd Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Sep 2023 13:55:19 -0500 Subject: [PATCH 09/58] Add test: testWriteLockOnJoinTableDoesntLimitQuery --- .../actions/RDBMSQueryActionJoinsTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 index d096b1cb..66ffdc52 100644 --- 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 @@ -39,7 +39,10 @@ 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.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.tables.QTableMetaData; 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; @@ -67,6 +70,7 @@ public class RDBMSQueryActionJoinsTest extends RDBMSActionTest super.primeTestDatabase(); AbstractRDBMSAction.setLogSQL(true); + AbstractRDBMSAction.setLogSQLReformat(true); AbstractRDBMSAction.setLogSQLOutput("system.out"); } @@ -79,6 +83,7 @@ public class RDBMSQueryActionJoinsTest extends RDBMSActionTest void afterEach() { AbstractRDBMSAction.setLogSQL(false); + AbstractRDBMSAction.setLogSQLReformat(false); } @@ -984,4 +989,43 @@ public class RDBMSQueryActionJoinsTest extends RDBMSActionTest assertThat(records).allMatch(r -> r.getValueInteger("id").equals(4) || r.getValueInteger("id").equals(5)); } + + + /******************************************************************************* + ** Addressing a regression where a table was brought into a query for its + ** security field, but it was a write-scope lock, so, it shouldn't have been. + *******************************************************************************/ + @Test + void testWriteLockOnJoinTableDoesntLimitQuery() throws Exception + { + /////////////////////////////////////////////////////////////////////// + // add a security key type for "idNumber" // + // then set up the person table with a read-write lock on that field // + /////////////////////////////////////////////////////////////////////// + QContext.getQInstance().addSecurityKeyType(new QSecurityKeyType().withName("idNumber")); + QTableMetaData personTable = QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON); + personTable.withRecordSecurityLock(new RecordSecurityLock() + .withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE) + .withSecurityKeyType("idNumber") + .withFieldName(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber") + .withJoinNameChain(List.of(QJoinMetaData.makeInferredJoinName(TestUtils.TABLE_NAME_PERSON, TestUtils.TABLE_NAME_PERSONAL_ID_CARD)))); + + ///////////////////////////////////////////////////////////////////////////////////////// + // first, with no idNumber security key in session, query on person should find 0 rows // + ///////////////////////////////////////////////////////////////////////////////////////// + assertEquals(0, new QueryAction().execute(new QueryInput(TestUtils.TABLE_NAME_PERSON)).getRecords().size()); + + /////////////////////////////////////////////////////////////////// + // put an idNumber in the session - query and find just that one // + /////////////////////////////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValue("idNumber", "19800531")); + assertEquals(1, new QueryAction().execute(new QueryInput(TestUtils.TABLE_NAME_PERSON)).getRecords().size()); + + ////////////////////////////////////////////////////////////////////////////////////////////// + // change the lock to be scope=WRITE - and now, we should be able to see all of the records // + ////////////////////////////////////////////////////////////////////////////////////////////// + personTable.getRecordSecurityLocks().get(0).setLockScope(RecordSecurityLock.LockScope.WRITE); + assertEquals(5, new QueryAction().execute(new QueryInput(TestUtils.TABLE_NAME_PERSON)).getRecords().size()); + } + } From bf0a554c6ac3b2c3c4969d8aaebab21e0d65c4d1 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Sep 2023 14:14:35 -0500 Subject: [PATCH 10/58] Instead of returning 1=1 if no clauses, make that return an optional, and handle smarter (avoid making a 1=1 OR , which borke some tests!) --- .../rdbms/actions/AbstractRDBMSAction.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) 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 1bf0b4fd..f19cb839 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 @@ -261,9 +261,12 @@ public abstract class AbstractRDBMSAction implements QActionInterface 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); + Optional securityOnClause = getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(joinsContext, queryJoin.getSecurityCriteria(), QQueryFilter.BooleanOperator.AND, params); + if(securityOnClause.isPresent()) + { + LOG.debug("Wrote securityOnClause", logPair("clause", securityOnClause)); + joinClauseList.add(securityOnClause.get()); + } } rs.append(" ON ").append(StringUtils.join(" AND ", joinClauseList)); @@ -361,23 +364,25 @@ public abstract class AbstractRDBMSAction implements QActionInterface return ("1 = 1"); } - String clause = getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(joinsContext, filter.getCriteria(), filter.getBooleanOperator(), params); + Optional clause = getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(joinsContext, filter.getCriteria(), filter.getBooleanOperator(), params); if(!CollectionUtils.nullSafeHasContents(filter.getSubFilters())) { /////////////////////////////////////////////////////////////// // if there are no sub-clauses, then just return this clause // + // and if there's no clause, use the default 1 = 1 // /////////////////////////////////////////////////////////////// - return (clause); + return (clause.orElse("1 = 1")); } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // else, build a list of clauses - recursively expanding the sub-filters into clauses, then return them joined with our operator // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// List clauses = new ArrayList<>(); - if(StringUtils.hasContent(clause)) + if(clause.isPresent() && StringUtils.hasContent(clause.get())) { - clauses.add("(" + clause + ")"); + clauses.add("(" + clause.get() + ")"); } + for(QQueryFilter subFilter : filter.getSubFilters()) { String subClause = makeWhereClause(joinsContext, subFilter, params); @@ -386,6 +391,7 @@ public abstract class AbstractRDBMSAction implements QActionInterface clauses.add("(" + subClause + ")"); } } + return (String.join(" " + filter.getBooleanOperator().toString() + " ", clauses)); } @@ -393,8 +399,9 @@ public abstract class AbstractRDBMSAction implements QActionInterface /******************************************************************************* ** + ** @return optional sql where sub-clause, as in "x AND y" *******************************************************************************/ - private String getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(JoinsContext joinsContext, List criteria, QQueryFilter.BooleanOperator booleanOperator, List params) throws IllegalArgumentException + private Optional getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(JoinsContext joinsContext, List criteria, QQueryFilter.BooleanOperator booleanOperator, List params) throws IllegalArgumentException { List clauses = new ArrayList<>(); for(QFilterCriteria criterion : criteria) @@ -642,15 +649,14 @@ public abstract class AbstractRDBMSAction implements QActionInterface ////////////////////////////////////////////////////////////////////////////// // since we're skipping criteria w/o a field or operator in the loop - // - // we can get to the end here without any clauses... so, return a 1=1 then, // - // as whoever called this is probably already written a WHERE or AND // + // we can get to the end here without any clauses... so, return a null here // ////////////////////////////////////////////////////////////////////////////// if(clauses.isEmpty()) { - return ("1 = 1"); + return (Optional.empty()); } - return (String.join(" " + booleanOperator.toString() + " ", clauses)); + return (Optional.of(String.join(" " + booleanOperator.toString() + " ", clauses))); } From bd01bed62ffb3acd0d313df8acd195ac34841838 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 12 Apr 2024 09:04:55 -0500 Subject: [PATCH 11/58] Fix to check if html2text is installed, before trying to report on un-covered classes (since that's the script we use, and we do install it in circleci) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 79723366..dbf6499a 100644 --- a/pom.xml +++ b/pom.xml @@ -255,7 +255,7 @@ else echo "xpath is not installed. Jacoco coverage summary will not be produced here..."; fi -which xpath > /dev/null 2>&1 +which html2text > /dev/null 2>&1 if [ "$?" == "0" ]; then echo "Untested classes, per Jacoco:" echo "-----------------------------" From eb47a4f3aba1e5af37a9921e339d5cfb37fb3c9b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 12 Apr 2024 09:19:45 -0500 Subject: [PATCH 12/58] Fix (?) for usage within maven, then checks for xpath and html2text for jacoco reporting --- pom.xml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index dbf6499a..0806452d 100644 --- a/pom.xml +++ b/pom.xml @@ -245,8 +245,7 @@ echo " See also target/site/jacoco/index.html" echo " and https://www.jacoco.org/jacoco/trunk/doc/counters.html" echo "------------------------------------------------------------" -which xpath > /dev/null 2>&1 -if [ "$?" == "0" ]; then +if which xpath > /dev/null 2>&1; then echo "Element\nInstructions Missed\nInstruction Coverage\nBranches Missed\nBranch Coverage\nComplexity Missed\nComplexity Hit\nLines Missed\nLines Hit\nMethods Missed\nMethods Hit\nClasses Missed\nClasses Hit\n" > /tmp/$$.headers xpath -n -q -e '/html/body/table/tfoot/tr[1]/td/text()' target/site/jacoco/index.html > /tmp/$$.values paste /tmp/$$.headers /tmp/$$.values | tail +2 | awk -v FS='\t' '{printf("%-20s %s\n",$1,$2)}' @@ -255,8 +254,7 @@ else echo "xpath is not installed. Jacoco coverage summary will not be produced here..."; fi -which html2text > /dev/null 2>&1 -if [ "$?" == "0" ]; then +if which html2text > /dev/null 2>&1; then echo "Untested classes, per Jacoco:" echo "-----------------------------" for i in target/site/jacoco/*/index.html; do From 27bbaf8afa786666d40d7ef127d04d49bcecd552 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 28 Mar 2024 14:21:39 -0500 Subject: [PATCH 13/58] Boosting some quartz test coverage --- .../scheduler/quartz/QuartzScheduler.java | 2 +- .../core/scheduler/QScheduleManagerTest.java | 62 +++------ .../core/scheduler/SchedulerTestUtils.java | 28 ++++ .../RescheduleAllJobsProcessTest.java | 128 ++++++++++++++++++ .../UnscheduleAllJobsProcessTest.java | 118 ++++++++++++++++ 5 files changed, 293 insertions(+), 45 deletions(-) create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/RescheduleAllJobsProcessTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/UnscheduleAllJobsProcessTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/quartz/QuartzScheduler.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/quartz/QuartzScheduler.java index f41362c5..da5002c8 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/quartz/QuartzScheduler.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/scheduler/quartz/QuartzScheduler.java @@ -701,7 +701,7 @@ public class QuartzScheduler implements QSchedulerInterface /******************************************************************************* ** *******************************************************************************/ - List queryQuartz() throws SchedulerException + public List queryQuartz() throws SchedulerException { return queryQuartzMemoization.getResultThrowing(AnyKey.getInstance(), (x) -> { diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/QScheduleManagerTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/QScheduleManagerTest.java index 68a7c37b..4b925a5f 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/QScheduleManagerTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/QScheduleManagerTest.java @@ -22,7 +22,6 @@ package com.kingsrook.qqq.backend.core.scheduler; -import java.util.ArrayList; import java.util.Map; import com.kingsrook.qqq.backend.core.BaseTest; import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus; @@ -32,8 +31,6 @@ import com.kingsrook.qqq.backend.core.logging.QCollectingLogger; 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.scheduleing.QScheduleMetaData; -import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob; -import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobParameter; import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobType; import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzScheduler; import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzTestUtils; @@ -83,29 +80,6 @@ class QScheduleManagerTest extends BaseTest - /******************************************************************************* - ** - *******************************************************************************/ - private ScheduledJob newScheduledJob(ScheduledJobType type, Map params) - { - ScheduledJob scheduledJob = new ScheduledJob() - .withId(1) - .withIsActive(true) - .withSchedulerName(TestUtils.SIMPLE_SCHEDULER_NAME) - .withType(type.name()) - .withRepeatSeconds(1) - .withJobParameters(new ArrayList<>()); - - for(Map.Entry entry : params.entrySet()) - { - scheduledJob.getJobParameters().add(new ScheduledJobParameter().withKey(entry.getKey()).withValue(entry.getValue())); - } - - return (scheduledJob); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -114,54 +88,54 @@ class QScheduleManagerTest extends BaseTest { QScheduleManager qScheduleManager = QScheduleManager.initInstance(QContext.getQInstance(), () -> QContext.getQSession()); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.PROCESS, Map.of()).withRepeatSeconds(null))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, Map.of()).withRepeatSeconds(null))) .hasMessageContaining("Missing a schedule"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.PROCESS, Map.of()).withType(null))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, Map.of()).withType(null))) .hasMessageContaining("Missing a type"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.PROCESS, Map.of()).withType("notAType"))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, Map.of()).withType("notAType"))) .hasMessageContaining("Unrecognized type"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.PROCESS, Map.of()))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, Map.of()))) .hasMessageContaining("Missing scheduledJobParameter with key [processName]"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.PROCESS, Map.of("processName", "notAProcess")))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, Map.of("processName", "notAProcess")))) .hasMessageContaining("Unrecognized processName"); QContext.getQInstance().getProcess(TestUtils.PROCESS_NAME_BASEPULL).withSchedule(new QScheduleMetaData()); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.PROCESS, Map.of("processName", TestUtils.PROCESS_NAME_BASEPULL)))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, Map.of("processName", TestUtils.PROCESS_NAME_BASEPULL)))) .hasMessageContaining("has a schedule in its metaData - so it should not be dynamically scheduled"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, Map.of()))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, Map.of()))) .hasMessageContaining("Missing scheduledJobParameter with key [queueName]"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, Map.of("queueName", "notAQueue")))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, Map.of("queueName", "notAQueue")))) .hasMessageContaining("Unrecognized queueName"); QContext.getQInstance().getQueue(TestUtils.TEST_SQS_QUEUE).withSchedule(new QScheduleMetaData()); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, Map.of("queueName", TestUtils.TEST_SQS_QUEUE)))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, Map.of("queueName", TestUtils.TEST_SQS_QUEUE)))) .hasMessageContaining("has a schedule in its metaData - so it should not be dynamically scheduled"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of()))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of()))) .hasMessageContaining("Missing scheduledJobParameter with key [tableName]"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", "notATable")))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", "notATable")))) .hasMessageContaining("Missing scheduledJobParameter with key [automationStatus]"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", "notATable", "automationStatus", AutomationStatus.PENDING_INSERT_AUTOMATIONS.name())))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", "notATable", "automationStatus", AutomationStatus.PENDING_INSERT_AUTOMATIONS.name())))) .hasMessageContaining("Unrecognized tableName"); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC, "automationStatus", AutomationStatus.PENDING_INSERT_AUTOMATIONS.name())))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC, "automationStatus", AutomationStatus.PENDING_INSERT_AUTOMATIONS.name())))) .hasMessageContaining("does not have automationDetails"); QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).getAutomationDetails().withSchedule(null); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY, "automationStatus", "foobar")))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY, "automationStatus", "foobar")))) .hasMessageContaining("Did not find table automation actions matching automationStatus") .hasMessageContaining("Found: PENDING_INSERT_AUTOMATIONS,PENDING_UPDATE_AUTOMATIONS"); QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).getAutomationDetails().withSchedule(new QScheduleMetaData()); - assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY, "automationStatus", AutomationStatus.PENDING_INSERT_AUTOMATIONS.name())))) + assertThatThrownBy(() -> qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY, "automationStatus", AutomationStatus.PENDING_INSERT_AUTOMATIONS.name())))) .hasMessageContaining("has a schedule in its metaData - so it should not be dynamically scheduled"); } @@ -181,19 +155,19 @@ class QScheduleManagerTest extends BaseTest QScheduleManager qScheduleManager = QScheduleManager.initInstance(qInstance, () -> QContext.getQSession()); qScheduleManager.start(); - qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.PROCESS, + qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, Map.of("processName", TestUtils.PROCESS_NAME_GREET_PEOPLE)) .withId(2) .withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)); qInstance.getQueue(TestUtils.TEST_SQS_QUEUE).setSchedule(null); - qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, + qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.QUEUE_PROCESSOR, Map.of("queueName", TestUtils.TEST_SQS_QUEUE)) .withId(3) .withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)); qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).getAutomationDetails().setSchedule(null); - qScheduleManager.setupScheduledJob(newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, + qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.TABLE_AUTOMATIONS, Map.of("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY, "automationStatus", AutomationStatus.PENDING_UPDATE_AUTOMATIONS.name())) .withId(4) .withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)); diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/SchedulerTestUtils.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/SchedulerTestUtils.java index 16b1a427..3a46ed37 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/SchedulerTestUtils.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/SchedulerTestUtils.java @@ -22,7 +22,9 @@ package com.kingsrook.qqq.backend.core.scheduler; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; @@ -31,6 +33,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; +import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob; +import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobParameter; +import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobType; +import com.kingsrook.qqq.backend.core.utils.TestUtils; /******************************************************************************* @@ -57,6 +63,28 @@ public class SchedulerTestUtils + /******************************************************************************* + ** + *******************************************************************************/ + public static ScheduledJob newScheduledJob(ScheduledJobType type, Map params) + { + ScheduledJob scheduledJob = new ScheduledJob() + .withId(1) + .withIsActive(true) + .withSchedulerName(TestUtils.SIMPLE_SCHEDULER_NAME) + .withType(type.name()) + .withRepeatSeconds(1) + .withJobParameters(new ArrayList<>()); + + for(Map.Entry entry : params.entrySet()) + { + scheduledJob.getJobParameters().add(new ScheduledJobParameter().withKey(entry.getKey()).withValue(entry.getValue())); + } + + return (scheduledJob); + } + + /******************************************************************************* ** diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/RescheduleAllJobsProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/RescheduleAllJobsProcessTest.java new file mode 100644 index 00000000..91f5de83 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/RescheduleAllJobsProcessTest.java @@ -0,0 +1,128 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.scheduler.processes; + + +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerHelper; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobType; +import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager; +import com.kingsrook.qqq.backend.core.scheduler.SchedulerTestUtils; +import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzJobAndTriggerWrapper; +import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzScheduler; +import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzTestUtils; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.quartz.SchedulerException; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Unit test for RescheduleAllJobsProcess + *******************************************************************************/ +class RescheduleAllJobsProcessTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void afterEach() + { + QLogger.deactivateCollectingLoggerForClass(QuartzScheduler.class); + + try + { + QScheduleManager.getInstance().unInit(); + } + catch(IllegalStateException ise) + { + ///////////////////////////////////////////////////////////////// + // ok, might just mean that this test didn't init the instance // + ///////////////////////////////////////////////////////////////// + } + + try + { + QuartzScheduler.getInstance().unInit(); + } + catch(IllegalStateException ise) + { + ///////////////////////////////////////////////////////////////// + // ok, might just mean that this test didn't init the instance // + ///////////////////////////////////////////////////////////////// + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException, SchedulerException + { + QInstance qInstance = QContext.getQInstance(); + MetaDataProducerHelper.processAllMetaDataProducersInPackage(qInstance, RescheduleAllJobsProcess.class.getPackageName()); + QuartzTestUtils.setupInstanceForQuartzTests(); + + QScheduleManager qScheduleManager = QScheduleManager.initInstance(qInstance, () -> QContext.getQSession()); + qScheduleManager.start(); + + qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, + Map.of("processName", TestUtils.PROCESS_NAME_GREET_PEOPLE)) + .withId(2) + .withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)); + + QuartzScheduler quartzScheduler = QuartzScheduler.getInstance(); + List wrappers = quartzScheduler.queryQuartz(); + + /////////////////////////////////////////////////////////////// + // make sure our scheduledJob here got scheduled with quartz // + /////////////////////////////////////////////////////////////// + assertTrue(wrappers.stream().anyMatch(w -> w.jobDetail().getKey().getName().equals("scheduledJob:2"))); + + ///////////////////////// + // run the re-schedule // + ///////////////////////// + RunProcessInput input = new RunProcessInput(); + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + input.setProcessName(RescheduleAllJobsProcess.class.getSimpleName()); + new RunProcessAction().execute(input); + + //////////////////////////////////////////////////////////////////////////////////////// + // now, because our scheduled job record isn't actually stored in ScheduledJob table, // + // when we reschdule all, it should become unscheduled. // + //////////////////////////////////////////////////////////////////////////////////////// + wrappers = quartzScheduler.queryQuartz(); + assertTrue(wrappers.stream().noneMatch(w -> w.jobDetail().getKey().getName().equals("scheduledJob:2"))); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/UnscheduleAllJobsProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/UnscheduleAllJobsProcessTest.java new file mode 100644 index 00000000..5b69d126 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/scheduler/processes/UnscheduleAllJobsProcessTest.java @@ -0,0 +1,118 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.scheduler.processes; + + +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerHelper; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobType; +import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager; +import com.kingsrook.qqq.backend.core.scheduler.SchedulerTestUtils; +import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzJobAndTriggerWrapper; +import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzScheduler; +import com.kingsrook.qqq.backend.core.scheduler.quartz.QuartzTestUtils; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.quartz.SchedulerException; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Unit test for UnscheduleAllJobsProcess + *******************************************************************************/ +class UnscheduleAllJobsProcessTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void afterEach() + { + QLogger.deactivateCollectingLoggerForClass(QuartzScheduler.class); + + try + { + QScheduleManager.getInstance().unInit(); + } + catch(IllegalStateException ise) + { + ///////////////////////////////////////////////////////////////// + // ok, might just mean that this test didn't init the instance // + ///////////////////////////////////////////////////////////////// + } + + try + { + QuartzScheduler.getInstance().unInit(); + } + catch(IllegalStateException ise) + { + ///////////////////////////////////////////////////////////////// + // ok, might just mean that this test didn't init the instance // + ///////////////////////////////////////////////////////////////// + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException, SchedulerException + { + QInstance qInstance = QContext.getQInstance(); + MetaDataProducerHelper.processAllMetaDataProducersInPackage(qInstance, UnscheduleAllJobsProcess.class.getPackageName()); + QuartzTestUtils.setupInstanceForQuartzTests(); + + QScheduleManager qScheduleManager = QScheduleManager.initInstance(qInstance, () -> QContext.getQSession()); + qScheduleManager.start(); + + qScheduleManager.setupScheduledJob(SchedulerTestUtils.newScheduledJob(ScheduledJobType.PROCESS, + Map.of("processName", TestUtils.PROCESS_NAME_GREET_PEOPLE)) + .withId(2) + .withSchedulerName(QuartzTestUtils.QUARTZ_SCHEDULER_NAME)); + + QuartzScheduler quartzScheduler = QuartzScheduler.getInstance(); + List wrappers = quartzScheduler.queryQuartz(); + assertEquals(1, wrappers.size()); + + RunProcessInput input = new RunProcessInput(); + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + input.setProcessName(UnscheduleAllJobsProcess.class.getSimpleName()); + new RunProcessAction().execute(input); + + wrappers = quartzScheduler.queryQuartz(); + assertTrue(wrappers.isEmpty()); + } + +} \ No newline at end of file From 974b90f0b72833a2c23ea4b197d160308ae2bdb5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 18 Apr 2024 20:44:38 -0500 Subject: [PATCH 14/58] make sure join-name-chain for record locks ends at the expected join table --- .../qqq/backend/core/instances/QInstanceValidator.java | 6 ++++++ .../qqq/backend/core/instances/QInstanceValidatorTest.java | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java index 85c7e307..e62725ec 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java @@ -749,6 +749,10 @@ public class QInstanceValidator { if(assertCondition(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " looks like a join (has a dot), but no joinNameChain was given.")) { + String[] split = fieldName.split("\\."); + String joinTableName = split[0]; + String joinFieldName = split[1]; + List joins = new ArrayList<>(); /////////////////////////////////////////////////////////////////////////////////////////////////// @@ -790,6 +794,8 @@ public class QInstanceValidator } } + assertCondition(Objects.equals(tmpTable.getName(), joinTableName), prefix + "has a joinNameChain doesn't end in the expected table [" + joinTableName + "]"); + assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java index e0eec66e..a9b490ad 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java @@ -55,10 +55,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType; import com.kingsrook.qqq.backend.core.model.metadata.dashboard.ParentWidgetMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.DateTimeDisplayValueBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; 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.fields.DateTimeDisplayValueBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; @@ -1902,6 +1902,7 @@ public class QInstanceValidatorTest extends BaseTest assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setJoinNameChain(new ArrayList<>())), "looks like a join (has a dot), but no joinNameChain was given"); assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setFieldName("storeId")), "does not look like a join (does not have a dot), but a joinNameChain was given"); assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setFieldName("order.wrongId")), "unrecognized fieldName: order.wrongId"); + assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setFieldName("lineItem.id")), "joinNameChain doesn't end in the expected table [lineItem]"); assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setJoinNameChain(List.of("notAJoin"))), "an unrecognized join"); assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setJoinNameChain(List.of("orderLineItem"))), "joinNameChain could not be followed through join"); } From 57bb39319f6b70fddeea226fab39748b8888ea80 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 18 Apr 2024 21:06:06 -0500 Subject: [PATCH 15/58] pin a static version of qfmd --- qqq-sample-project/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qqq-sample-project/pom.xml b/qqq-sample-project/pom.xml index 11c70b15..9d9ea946 100644 --- a/qqq-sample-project/pom.xml +++ b/qqq-sample-project/pom.xml @@ -68,7 +68,7 @@ com.kingsrook.qqq qqq-frontend-material-dashboard - ${revision} + 0.20.0-20240418.180316-42 com.h2database From e9bcec4c07dfe8c312de66b75112e7034756ac48 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 Apr 2024 08:34:12 -0500 Subject: [PATCH 16/58] CE-882 Add methods to make newSession (either with default user id, or specified user id) --- .../kingsrook/qqq/backend/core/BaseTest.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/BaseTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/BaseTest.java index 27513ccb..dbe4164c 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/BaseTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/BaseTest.java @@ -40,6 +40,8 @@ public class BaseTest { private static final QLogger LOG = QLogger.getLogger(BaseTest.class); + public static final String DEFAULT_USER_ID = "001"; + /******************************************************************************* @@ -50,15 +52,34 @@ public class BaseTest { System.setProperty("qqq.logger.logSessionId.disabled", "true"); - QContext.init(TestUtils.defineInstance(), new QSession() - .withUser(new QUser() - .withIdReference("001") - .withFullName("Anonymous"))); + QContext.init(TestUtils.defineInstance(), newSession()); resetMemoryRecordStore(); } + /******************************************************************************* + ** + *******************************************************************************/ + protected QSession newSession() + { + return newSession(DEFAULT_USER_ID); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + protected QSession newSession(String userId) + { + return new QSession().withUser(new QUser() + .withIdReference(userId) + .withFullName("Anonymous")); + } + + + /******************************************************************************* ** *******************************************************************************/ From 97132665a87b3ff266e671b54b07bf9fcfb51bf9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 Apr 2024 08:43:53 -0500 Subject: [PATCH 17/58] CE-882 Initial build of processes to manage shared records --- .../sharing/DeleteSharedRecordProcess.java | 130 ++++++++++ .../sharing/EditSharedRecordProcess.java | 140 ++++++++++ .../sharing/GetSharedRecordsProcess.java | 243 ++++++++++++++++++ .../sharing/InsertSharedRecordProcess.java | 186 ++++++++++++++ .../implementations/sharing/ShareScope.java | 81 ++++++ .../sharing/SharedRecordProcessUtils.java | 126 +++++++++ .../sharing/SharingMetaDataProvider.java | 61 +++++ .../DeleteSharedRecordProcessTest.java | 132 ++++++++++ .../sharing/EditSharedRecordProcessTest.java | 144 +++++++++++ .../sharing/GetSharedRecordsProcessTest.java | 98 +++++++ .../InsertSharedRecordProcessTest.java | 146 +++++++++++ .../sharing/SharingMetaDataProviderTest.java | 46 ++++ 12 files changed, 1533 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcess.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcess.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/ShareScope.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharedRecordProcessUtils.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProvider.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcessTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcessTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcessTest.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProviderTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcess.java new file mode 100644 index 00000000..ea73e9ff --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcess.java @@ -0,0 +1,130 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import java.util.List; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +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.code.QCodeReference; +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.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; + + +/******************************************************************************* + ** DeleteSharedRecord: {tableName; recordId; shareId;} + *******************************************************************************/ +public class DeleteSharedRecordProcess implements BackendStep, MetaDataProducerInterface +{ + public static final String NAME = "deleteSharedRecord"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QProcessMetaData produce(QInstance qInstance) throws QException + { + return new QProcessMetaData() + .withName(NAME) + .withIcon(new QIcon().withName("share")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)) // todo confirm or protect + .withStepList(List.of( + new QBackendStepMetaData() + .withName("execute") + .withCode(new QCodeReference(getClass())) + .withInputData(new QFunctionInputMetaData() + .withField(new QFieldMetaData("tableName", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME)) // todo - actually only a subset of this... + .withField(new QFieldMetaData("recordId", QFieldType.STRING)) + .withField(new QFieldMetaData("shareId", QFieldType.INTEGER)) + ) + )); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException + { + String tableName = runBackendStepInput.getValueString("tableName"); + String recordIdString = runBackendStepInput.getValueString("recordId"); + Integer shareId = runBackendStepInput.getValueInteger("shareId"); + + Objects.requireNonNull(tableName, "Missing required input: tableName"); + Objects.requireNonNull(recordIdString, "Missing required input: recordId"); + Objects.requireNonNull(shareId, "Missing required input: shareId"); + + try + { + SharedRecordProcessUtils.AssetTableAndRecord assetTableAndRecord = SharedRecordProcessUtils.getAssetTableAndRecord(tableName, recordIdString); + + ShareableTableMetaData shareableTableMetaData = assetTableAndRecord.shareableTableMetaData(); + QRecord assetRecord = assetTableAndRecord.record(); + + SharedRecordProcessUtils.assertRecordOwnership(shareableTableMetaData, assetRecord, "delete shares of"); + + /////////////////// + // do the delete // + /////////////////// + DeleteOutput deleteOutput = new DeleteAction().execute(new DeleteInput(shareableTableMetaData.getSharedRecordTableName()).withPrimaryKeys(List.of(shareId))); + + ////////////////////// + // check for errors // + ////////////////////// + if(CollectionUtils.nullSafeHasContents(deleteOutput.getRecordsWithErrors())) + { + throw (new QException("Error deleting shared record: " + deleteOutput.getRecordsWithErrors().get(0).getErrors().get(0).getMessage())); + } + } + catch(QException qe) + { + throw (qe); + } + catch(Exception e) + { + throw (new QException("Error deleting shared record", e)); + } + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcess.java new file mode 100644 index 00000000..406bea55 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcess.java @@ -0,0 +1,140 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import java.util.List; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; +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.code.QCodeReference; +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.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareScopePossibleValueMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; + + +/******************************************************************************* + ** EditSharedRecord: {tableName; recordId; shareId; scopeId;} + *******************************************************************************/ +public class EditSharedRecordProcess implements BackendStep, MetaDataProducerInterface +{ + public static final String NAME = "editSharedRecord"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QProcessMetaData produce(QInstance qInstance) throws QException + { + return new QProcessMetaData() + .withName(NAME) + .withIcon(new QIcon().withName("share")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)) // todo confirm or protect + .withStepList(List.of( + new QBackendStepMetaData() + .withName("execute") + .withCode(new QCodeReference(getClass())) + .withInputData(new QFunctionInputMetaData() + .withField(new QFieldMetaData("tableName", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME)) // todo - actually only a subset of this... + .withField(new QFieldMetaData("recordId", QFieldType.STRING)) + .withField(new QFieldMetaData("scopeId", QFieldType.STRING).withPossibleValueSourceName(ShareScopePossibleValueMetaDataProducer.NAME)) + .withField(new QFieldMetaData("shareId", QFieldType.INTEGER)) + ) + )); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException + { + String tableName = runBackendStepInput.getValueString("tableName"); + String recordIdString = runBackendStepInput.getValueString("recordId"); + String scopeId = runBackendStepInput.getValueString("scopeId"); + Integer shareId = runBackendStepInput.getValueInteger("shareId"); + + Objects.requireNonNull(tableName, "Missing required input: tableName"); + Objects.requireNonNull(recordIdString, "Missing required input: recordId"); + Objects.requireNonNull(scopeId, "Missing required input: scopeId"); + Objects.requireNonNull(shareId, "Missing required input: shareId"); + + try + { + SharedRecordProcessUtils.AssetTableAndRecord assetTableAndRecord = SharedRecordProcessUtils.getAssetTableAndRecord(tableName, recordIdString); + + ShareableTableMetaData shareableTableMetaData = assetTableAndRecord.shareableTableMetaData(); + QRecord assetRecord = assetTableAndRecord.record(); + QTableMetaData shareTable = QContext.getQInstance().getTable(shareableTableMetaData.getSharedRecordTableName()); + + SharedRecordProcessUtils.assertRecordOwnership(shareableTableMetaData, assetRecord, "edit shares of"); + ShareScope shareScope = SharedRecordProcessUtils.validateScopeId(scopeId); + + /////////////////// + // do the insert // + /////////////////// + UpdateOutput updateOutput = new UpdateAction().execute(new UpdateInput(shareableTableMetaData.getSharedRecordTableName()).withRecord(new QRecord() + .withValue(shareTable.getPrimaryKeyField(), shareId) + .withValue(shareableTableMetaData.getScopeFieldName(), shareScope.getPossibleValueId()))); + + ////////////////////// + // check for errors // + ////////////////////// + if(CollectionUtils.nullSafeHasContents(updateOutput.getRecords().get(0).getErrors())) + { + throw (new QException("Error editing shared record: " + updateOutput.getRecords().get(0).getErrors().get(0).getMessage())); + } + } + catch(QException qe) + { + throw (qe); + } + catch(Exception e) + { + throw (new QException("Error editing shared record", e)); + } + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java new file mode 100644 index 00000000..3b898f65 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java @@ -0,0 +1,243 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +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.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +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.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.code.QCodeReference; +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.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableAudienceType; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.ListingHash; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** GetSharedRecords: {tableName; recordId;} => [{id; audienceType; audienceId; audienceLabel; scopeId}] + *******************************************************************************/ +public class GetSharedRecordsProcess implements BackendStep, MetaDataProducerInterface +{ + public static final String NAME = "getSharedRecords"; + + private static final QLogger LOG = QLogger.getLogger(GetSharedRecordsProcess.class); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QProcessMetaData produce(QInstance qInstance) throws QException + { + return new QProcessMetaData() + .withName(NAME) + .withIcon(new QIcon().withName("share")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)) // todo confirm or protect + .withStepList(List.of( + new QBackendStepMetaData() + .withName("execute") + .withCode(new QCodeReference(getClass())) + .withInputData(new QFunctionInputMetaData() + .withField(new QFieldMetaData("tableName", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME)) // todo - actually only a subset of this... + .withField(new QFieldMetaData("recordId", QFieldType.STRING)) + ) + )); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException + { + String tableName = runBackendStepInput.getValueString("tableName"); + String recordIdString = runBackendStepInput.getValueString("recordId"); + + Objects.requireNonNull(tableName, "Missing required input: tableName"); + Objects.requireNonNull(recordIdString, "Missing required input: recordId"); + + try + { + SharedRecordProcessUtils.AssetTableAndRecord assetTableAndRecord = SharedRecordProcessUtils.getAssetTableAndRecord(tableName, recordIdString); + + ShareableTableMetaData shareableTableMetaData = assetTableAndRecord.shareableTableMetaData(); + QTableMetaData shareTable = QContext.getQInstance().getTable(shareableTableMetaData.getSharedRecordTableName()); + Serializable recordId = assetTableAndRecord.recordId(); + + ///////////////////////////////////// + // query for shares on this record // + ///////////////////////////////////// + QueryInput queryInput = new QueryInput(shareTable.getName()); + queryInput.setFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria(shareableTableMetaData.getAssetIdFieldName(), QCriteriaOperator.EQUALS, recordId)) + .withOrderBy(new QFilterOrderBy(shareTable.getPrimaryKeyField())) + ); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // iterate results, building QRecords to output - note - we'll need to collect ids, then look them up in audience-source tables // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + ArrayList resultList = new ArrayList<>(); + ListingHash audienceIds = new ListingHash<>(); + for(QRecord record : queryOutput.getRecords()) + { + QRecord outputRecord = new QRecord(); + outputRecord.setValue("shareId", record.getValue(shareTable.getPrimaryKeyField())); + outputRecord.setValue("scopeId", record.getValue(shareableTableMetaData.getScopeFieldName())); + + boolean foundAudienceType = false; + for(ShareableAudienceType audienceType : shareableTableMetaData.getAudienceTypes().values()) + { + Serializable audienceId = record.getValueString(audienceType.getFieldName()); + if(audienceId != null) + { + outputRecord.setValue("audienceType", audienceType.getName()); + outputRecord.setValue("audienceId", audienceId); + audienceIds.add(audienceType.getName(), audienceId); + foundAudienceType = true; + break; + } + } + + if(!foundAudienceType) + { + LOG.warn("Failed to find what audience type to use for a shared record", + logPair("sharedTableName", shareTable.getName()), + logPair("id", record.getValue(shareTable.getPrimaryKeyField())), + logPair("recordId", record.getValue(shareableTableMetaData.getAssetIdFieldName()))); + continue; + } + + resultList.add(outputRecord); + } + + ///////////////////////////////// + // look up the audience labels // + ///////////////////////////////// + Map> audienceLabels = new HashMap<>(); + Set audienceTypesWithLabels = new HashSet<>(); + for(Map.Entry> entry : audienceIds.entrySet()) + { + String audienceType = entry.getKey(); + List ids = entry.getValue(); + if(CollectionUtils.nullSafeHasContents(ids)) + { + ShareableAudienceType shareableAudienceType = shareableTableMetaData.getAudienceTypes().get(audienceType); + if(StringUtils.hasContent(shareableAudienceType.getSourceTableName())) + { + audienceTypesWithLabels.add(audienceType); + + String keyField = shareableAudienceType.getSourceTableKeyFieldName(); + if(!StringUtils.hasContent(keyField)) + { + keyField = QContext.getQInstance().getTable(shareableAudienceType.getSourceTableName()).getPrimaryKeyField(); + } + + QueryInput audienceQueryInput = new QueryInput(shareableAudienceType.getSourceTableName()); + audienceQueryInput.setFilter(new QQueryFilter(new QFilterCriteria(keyField, QCriteriaOperator.IN, ids))); + audienceQueryInput.setShouldGenerateDisplayValues(true); // to get record labels + QueryOutput audienceQueryOutput = new QueryAction().execute(audienceQueryInput); + for(QRecord audienceRecord : audienceQueryOutput.getRecords()) + { + audienceLabels.computeIfAbsent(audienceType, k -> new HashMap<>()); + audienceLabels.get(audienceType).put(audienceRecord.getValue(keyField), audienceRecord.getRecordLabel()); + } + } + } + } + + //////////////////////////////////////////// + // put those labels on the output records // + //////////////////////////////////////////// + for(QRecord outputRecord : resultList) + { + String audienceType = outputRecord.getValueString("audienceType"); + Map typeLabels = audienceLabels.getOrDefault(audienceType, Collections.emptyMap()); + Serializable audienceId = outputRecord.getValue("audienceId"); + String label = typeLabels.get(audienceId); + if(StringUtils.hasContent(label)) + { + outputRecord.setValue("audienceLabel", label); + } + else + { + if(audienceTypesWithLabels.contains(audienceType)) + { + outputRecord.setValue("audienceLabel", "Unknown " + audienceType + " (id=" + audienceId + ")"); + } + else + { + outputRecord.setValue("audienceLabel", audienceType + " " + audienceId); + } + } + } + + runBackendStepOutput.addValue("resultList", resultList); + } + catch(QException qe) + { + throw (qe); + } + catch(Exception e) + { + throw (new QException("Error getting shared records.", e)); + } + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java new file mode 100644 index 00000000..4d449ede --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java @@ -0,0 +1,186 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import java.io.Serializable; +import java.util.List; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +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.code.QCodeReference; +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.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareScopePossibleValueMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableAudienceType; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.QErrorMessage; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** InsertSharedRecord: {tableName; recordId; audienceType; audienceId; scopeId;} + *******************************************************************************/ +public class InsertSharedRecordProcess implements BackendStep, MetaDataProducerInterface +{ + public static final String NAME = "insertSharedRecord"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QProcessMetaData produce(QInstance qInstance) throws QException + { + return new QProcessMetaData() + .withName(NAME) + .withIcon(new QIcon().withName("share")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)) // todo confirm or protect + .withStepList(List.of( + new QBackendStepMetaData() + .withName("execute") + .withCode(new QCodeReference(getClass())) + .withInputData(new QFunctionInputMetaData() + .withField(new QFieldMetaData("tableName", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME)) // todo - actually only a subset of this... + .withField(new QFieldMetaData("recordId", QFieldType.STRING)) + .withField(new QFieldMetaData("audienceType", QFieldType.STRING)) // todo take a PVS name as param? + .withField(new QFieldMetaData("audienceId", QFieldType.STRING)) + .withField(new QFieldMetaData("scopeId", QFieldType.STRING).withPossibleValueSourceName(ShareScopePossibleValueMetaDataProducer.NAME)) + ) + )); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException + { + String tableName = runBackendStepInput.getValueString("tableName"); + String recordIdString = runBackendStepInput.getValueString("recordId"); + String audienceType = runBackendStepInput.getValueString("audienceType"); + String audienceIdString = runBackendStepInput.getValueString("audienceId"); + String scopeId = runBackendStepInput.getValueString("scopeId"); + + Objects.requireNonNull(tableName, "Missing required input: tableName"); + Objects.requireNonNull(recordIdString, "Missing required input: recordId"); + Objects.requireNonNull(audienceType, "Missing required input: audienceType"); + Objects.requireNonNull(audienceIdString, "Missing required input: audienceId"); + Objects.requireNonNull(scopeId, "Missing required input: scopeId"); + + try + { + SharedRecordProcessUtils.AssetTableAndRecord assetTableAndRecord = SharedRecordProcessUtils.getAssetTableAndRecord(tableName, recordIdString); + + ShareableTableMetaData shareableTableMetaData = assetTableAndRecord.shareableTableMetaData(); + QRecord assetRecord = assetTableAndRecord.record(); + Serializable recordId = assetTableAndRecord.recordId(); + + SharedRecordProcessUtils.assertRecordOwnership(shareableTableMetaData, assetRecord, "share"); + + //////////////////////////////// + // validate the audience type // + //////////////////////////////// + ShareableAudienceType shareableAudienceType = shareableTableMetaData.getAudienceTypes().get(audienceType); + if(shareableAudienceType == null) + { + throw (new QException("[" + audienceType + "] is not a recognized audience type for sharing records from the " + tableName + " table. Allowed values are: " + shareableTableMetaData.getAudienceTypes().keySet())); + } + + /////////////////////////////////////////////////////////////////////////////////////////////// + // if we know the audience source-table, then fetch & validate security-wise the audience id // + /////////////////////////////////////////////////////////////////////////////////////////////// + Serializable audienceId = audienceIdString; + if(StringUtils.hasContent(shareableAudienceType.getSourceTableName())) + { + QTableMetaData audienceTable = QContext.getQInstance().getTable(shareableAudienceType.getSourceTableName()); + audienceId = ValueUtils.getValueAsFieldType(audienceTable.getField(audienceTable.getPrimaryKeyField()).getType(), audienceIdString); + QRecord audienceRecord = new GetAction().executeForRecord(new GetInput(audienceTable.getName()).withPrimaryKey(audienceId)); + if(audienceRecord == null) + { + throw (new QException("A record could not be found for audience type " + audienceType + ", audience id: " + audienceIdString)); + } + } + + //////////////////////////////// + // validate input share scope // + //////////////////////////////// + ShareScope shareScope = SharedRecordProcessUtils.validateScopeId(scopeId); + + /////////////////// + // do the insert // + /////////////////// + InsertOutput insertOutput = new InsertAction().execute(new InsertInput(shareableTableMetaData.getSharedRecordTableName()).withRecord(new QRecord() + .withValue(shareableTableMetaData.getAssetIdFieldName(), recordId) + .withValue(shareableTableMetaData.getScopeFieldName(), shareScope.getPossibleValueId()) + .withValue(shareableAudienceType.getFieldName(), audienceId))); + + ////////////////////// + // check for errors // + ////////////////////// + if(CollectionUtils.nullSafeHasContents(insertOutput.getRecords().get(0).getErrors())) + { + QErrorMessage errorMessage = insertOutput.getRecords().get(0).getErrors().get(0); + if(errorMessage instanceof BadInputStatusMessage) + { + throw (new QUserFacingException(errorMessage.getMessage())); + } + throw (new QException("Error inserting shared record: " + errorMessage.getMessage())); + } + } + catch(QException qe) + { + throw (qe); + } + catch(Exception e) + { + throw (new QException("Error inserting shared record", e)); + } + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/ShareScope.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/ShareScope.java new file mode 100644 index 00000000..720402cc --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/ShareScope.java @@ -0,0 +1,81 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum; + + +/******************************************************************************* + ** for a shared record, what scope of access is given. + *******************************************************************************/ +public enum ShareScope implements PossibleValueEnum +{ + READ_ONLY("Read Only"), + READ_WRITE("Read and Edit"); + + + private final String label; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + ShareScope(String label) + { + this.label = label; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getPossibleValueId() + { + return name(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getPossibleValueLabel() + { + return label; + } + + + + /******************************************************************************* + ** Getter for label + ** + *******************************************************************************/ + public String getLabel() + { + return label; + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharedRecordProcessUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharedRecordProcessUtils.java new file mode 100644 index 00000000..6cd37a1e --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharedRecordProcessUtils.java @@ -0,0 +1,126 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +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.get.GetInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class SharedRecordProcessUtils +{ + /******************************************************************************* + ** + *******************************************************************************/ + record AssetTableAndRecord(QTableMetaData table, ShareableTableMetaData shareableTableMetaData, QRecord record, Serializable recordId) {} + + + + /******************************************************************************* + ** + *******************************************************************************/ + static AssetTableAndRecord getAssetTableAndRecord(String tableName, String recordIdString) throws QException + { + ////////////////////////////// + // validate the asset table // + ////////////////////////////// + QTableMetaData assetTable = QContext.getQInstance().getTable(tableName); + if(assetTable == null) + { + throw (new QException("The specified tableName, " + tableName + ", was not found.")); + } + + ShareableTableMetaData shareableTableMetaData = assetTable.getShareableTableMetaData(); + if(shareableTableMetaData == null) + { + throw (new QException("The specified tableName, " + tableName + ", is not shareable.")); + } + + ////////////////////////////// + // look up the asset record // + ////////////////////////////// + Serializable recordId = ValueUtils.getValueAsFieldType(assetTable.getField(assetTable.getPrimaryKeyField()).getType(), recordIdString); + QRecord assetRecord = new GetAction().executeForRecord(new GetInput(tableName).withPrimaryKey(recordId)); + if(assetRecord == null) + { + throw (new QException("A record could not be found in table, " + tableName + ", with primary key: " + recordIdString)); + } + + return new AssetTableAndRecord(assetTable, shareableTableMetaData, assetRecord, recordId); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static void assertRecordOwnership(ShareableTableMetaData shareableTableMetaData, QRecord assetRecord, String verbClause) throws QException + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the shareable meta-data says this-table's owner id, then validate that the current user own the record // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(StringUtils.hasContent(shareableTableMetaData.getThisTableOwnerIdFieldName())) + { + Serializable ownerId = assetRecord.getValue(shareableTableMetaData.getThisTableOwnerIdFieldName()); + if(!Objects.equals(ownerId, QContext.getQSession().getUser().getIdReference())) + { + throw (new QException("You are not the owner of this record, so you may not " + verbClause + " it.")); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static ShareScope validateScopeId(String scopeId) throws QException + { + //////////////////////////////// + // validate input share scope // + //////////////////////////////// + ShareScope shareScope = null; + try + { + shareScope = ShareScope.valueOf(scopeId); + return (shareScope); + } + catch(IllegalArgumentException e) + { + throw (new QException("[" + shareScope + "] is not a recognized value for shareScope. Allowed values are: " + Arrays.toString(ShareScope.values()))); + } + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProvider.java new file mode 100644 index 00000000..ffbb13f4 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProvider.java @@ -0,0 +1,61 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class SharingMetaDataProvider +{ + + /******************************************************************************* + ** + *******************************************************************************/ + public void defineAll(QInstance instance, Consumer processEnricher) throws QException + { + List processes = new ArrayList<>(); + processes.add(new GetSharedRecordsProcess().produce(instance)); + processes.add(new InsertSharedRecordProcess().produce(instance)); + processes.add(new EditSharedRecordProcess().produce(instance)); + processes.add(new DeleteSharedRecordProcess().produce(instance)); + + for(QProcessMetaData process : processes) + { + if(processEnricher != null) + { + processEnricher.accept(process); + } + + instance.addProcess(process); + } + } + +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcessTest.java new file mode 100644 index 00000000..38a9e731 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/DeleteSharedRecordProcessTest.java @@ -0,0 +1,132 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.savedreports.ReportColumns; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReport; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.savedreports.SharedSavedReport; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for DeleteSharedRecordProcess + *******************************************************************************/ +class DeleteSharedRecordProcessTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws Exception + { + new SavedReportsMetaDataProvider().defineAll(QContext.getQInstance(), TestUtils.MEMORY_BACKEND_NAME, TestUtils.MEMORY_BACKEND_NAME, null); + + new InsertAction().execute(new InsertInput(SavedReport.TABLE_NAME).withRecordEntity(new SavedReport() + .withTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withLabel("Test") + .withColumnsJson(JsonUtils.toJson(new ReportColumns().withColumn("id"))) + )); + + new InsertAction().execute(new InsertInput(SharedSavedReport.TABLE_NAME).withRecordEntity(new SharedSavedReport() + .withSavedReportId(1) + .withUserId(BaseTest.DEFAULT_USER_ID) + .withScope(ShareScope.READ_WRITE.getPossibleValueId()) + )); + + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFailCases() throws QException + { + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + DeleteSharedRecordProcess processStep = new DeleteSharedRecordProcess(); + + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: tableName"); + input.addValue("tableName", SavedReport.TABLE_NAME); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: recordId"); + input.addValue("recordId", 1); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: shareId"); + input.addValue("shareId", 3); + + /////////////////////////////////////////////////// + // fail because the requested record isn't found // + /////////////////////////////////////////////////// + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Error deleting shared record: No record was found to delete"); + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // now fail because a different user (than the owner, who did the initial delete) is trying to share // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + QContext.setQSession(newSession("not-" + DEFAULT_USER_ID)); + input.addValue("shareId", 1); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("not the owner of this record"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSuccess() throws QException + { + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + DeleteSharedRecordProcess processStep = new DeleteSharedRecordProcess(); + + input.addValue("tableName", SavedReport.TABLE_NAME); + input.addValue("recordId", 1); + input.addValue("shareId", 1); + + ////////////////////////////////////////// + // assert the shared record got deleted // + ////////////////////////////////////////// + processStep.run(input, output); + + QRecord sharedSavedReportRecord = new GetAction().executeForRecord(new GetInput(SharedSavedReport.TABLE_NAME).withPrimaryKey(1)); + assertNull(sharedSavedReportRecord); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcessTest.java new file mode 100644 index 00000000..1d55f4eb --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/EditSharedRecordProcessTest.java @@ -0,0 +1,144 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.savedreports.ReportColumns; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReport; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.savedreports.SharedSavedReport; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for EditSharedRecordProcess + *******************************************************************************/ +class EditSharedRecordProcessTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws Exception + { + new SavedReportsMetaDataProvider().defineAll(QContext.getQInstance(), TestUtils.MEMORY_BACKEND_NAME, TestUtils.MEMORY_BACKEND_NAME, null); + + new InsertAction().execute(new InsertInput(SavedReport.TABLE_NAME).withRecordEntity(new SavedReport() + .withTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withLabel("Test") + .withColumnsJson(JsonUtils.toJson(new ReportColumns().withColumn("id"))) + )); + + new InsertAction().execute(new InsertInput(SharedSavedReport.TABLE_NAME).withRecordEntity(new SharedSavedReport() + .withSavedReportId(1) + .withUserId(BaseTest.DEFAULT_USER_ID) + .withScope(ShareScope.READ_WRITE.getPossibleValueId()) + )); + + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFailCases() throws QException + { + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + EditSharedRecordProcess processStep = new EditSharedRecordProcess(); + + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: tableName"); + input.addValue("tableName", SavedReport.TABLE_NAME); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: recordId"); + input.addValue("recordId", 1); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: scopeId"); + input.addValue("scopeId", ShareScope.READ_WRITE.getPossibleValueId()); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: shareId"); + input.addValue("shareId", 3); + + /////////////////////////////////////////////////// + // fail because the requested record isn't found // + /////////////////////////////////////////////////// + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Error editing shared record: No record was found to update"); + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // now fail because a different user (than the owner, who did the initial edit) is trying to share // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + QContext.setQSession(newSession("not-" + DEFAULT_USER_ID)); + input.addValue("shareId", 1); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("not the owner of this record"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSuccess() throws QException + { + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + EditSharedRecordProcess processStep = new EditSharedRecordProcess(); + + /////////////////////////// + // assert original value // + /////////////////////////// + QRecord sharedSavedReportRecord = new GetAction().executeForRecord(new GetInput(SharedSavedReport.TABLE_NAME).withPrimaryKey(1)); + assertEquals(ShareScope.READ_WRITE.getPossibleValueId(), sharedSavedReportRecord.getValueString("scope")); + + input.addValue("tableName", SavedReport.TABLE_NAME); + input.addValue("recordId", 1); + input.addValue("shareId", 1); + input.addValue("scopeId", ShareScope.READ_ONLY.getPossibleValueId()); + + ///////////////////////////////////////// + // assert the shared record got edited // + ///////////////////////////////////////// + processStep.run(input, output); + + ////////////////////////// + // assert updated value // + ////////////////////////// + sharedSavedReportRecord = new GetAction().executeForRecord(new GetInput(SharedSavedReport.TABLE_NAME).withPrimaryKey(1)); + assertEquals(ShareScope.READ_ONLY.getPossibleValueId(), sharedSavedReportRecord.getValueString("scope")); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java new file mode 100644 index 00000000..9a7fdb63 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java @@ -0,0 +1,98 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.savedreports.ReportColumns; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReport; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.savedreports.SharedSavedReport; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for GetSharedRecordsProcess + *******************************************************************************/ +class GetSharedRecordsProcessTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws Exception + { + new SavedReportsMetaDataProvider().defineAll(QContext.getQInstance(), TestUtils.MEMORY_BACKEND_NAME, TestUtils.MEMORY_BACKEND_NAME, null); + + new InsertAction().execute(new InsertInput(SavedReport.TABLE_NAME).withRecordEntity(new SavedReport() + .withTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withLabel("Test") + .withColumnsJson(JsonUtils.toJson(new ReportColumns().withColumn("id"))))); + + new InsertAction().execute(new InsertInput(SharedSavedReport.TABLE_NAME).withRecordEntity(new SharedSavedReport() + .withScope(ShareScope.READ_WRITE.getPossibleValueId()) + .withUserId("007") + .withSavedReportId(1) + )); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + GetSharedRecordsProcess processStep = new GetSharedRecordsProcess(); + + input.addValue("tableName", SavedReport.TABLE_NAME); + input.addValue("recordId", 1); + processStep.run(input, output); + + List resultList = (List) output.getValue("resultList"); + assertEquals(1, resultList.size()); + + QRecord outputRecord = resultList.get(0); + assertEquals(1, outputRecord.getValueInteger("id")); + assertEquals(ShareScope.READ_WRITE.getPossibleValueId(), outputRecord.getValueString("scopeId")); + assertEquals("user", outputRecord.getValueString("audienceType")); + assertEquals("007", outputRecord.getValueString("audienceId")); + assertEquals("user 007", outputRecord.getValueString("audienceLabel")); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcessTest.java new file mode 100644 index 00000000..67120798 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcessTest.java @@ -0,0 +1,146 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.savedreports.ReportColumns; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReport; +import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.savedreports.SharedSavedReport; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/******************************************************************************* + ** Unit test for InsertSharedRecordProcess + *******************************************************************************/ +class InsertSharedRecordProcessTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws Exception + { + new SavedReportsMetaDataProvider().defineAll(QContext.getQInstance(), TestUtils.MEMORY_BACKEND_NAME, TestUtils.MEMORY_BACKEND_NAME, null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFailCases() throws QException + { + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + InsertSharedRecordProcess processStep = new InsertSharedRecordProcess(); + + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: tableName"); + input.addValue("tableName", SavedReport.TABLE_NAME); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: recordId"); + input.addValue("recordId", 1); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: audienceType"); + input.addValue("audienceType", "user"); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: audienceId"); + input.addValue("audienceId", "darin@kingsrook.com"); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("Missing required input: scopeId"); + input.addValue("scopeId", ShareScope.READ_WRITE); + + ////////////////////////////// + // try a non-sharable table // + ////////////////////////////// + input.addValue("tableName", TestUtils.TABLE_NAME_PERSON_MEMORY); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("is not shareable"); + input.addValue("tableName", SavedReport.TABLE_NAME); + + /////////////////////////////////////////////////// + // fail because the requested record isn't found // + /////////////////////////////////////////////////// + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("record could not be found in table, savedReport, with primary key: 1"); + new InsertAction().execute(new InsertInput(SavedReport.TABLE_NAME).withRecordEntity(new SavedReport() + .withTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withLabel("Test") + .withColumnsJson(JsonUtils.toJson(new ReportColumns().withColumn("id"))) + )); + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // now fail because a different user (than the owner, who did the initial insert) is trying to share // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + QContext.setQSession(newSession("not-" + DEFAULT_USER_ID)); + assertThatThrownBy(() -> processStep.run(input, output)).hasMessageContaining("not the owner of this record"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testSuccess() throws QException + { + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + InsertSharedRecordProcess processStep = new InsertSharedRecordProcess(); + + input.addValue("tableName", SavedReport.TABLE_NAME); + input.addValue("recordId", 1); + input.addValue("audienceType", "user"); + input.addValue("audienceId", "darin@kingsrook.com"); + input.addValue("scopeId", ShareScope.READ_WRITE); + + new InsertAction().execute(new InsertInput(SavedReport.TABLE_NAME).withRecordEntity(new SavedReport() + .withTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withLabel("Test") + .withColumnsJson(JsonUtils.toJson(new ReportColumns().withColumn("id"))) + )); + + //////////////////////////////////////// + // assert the shared record got built // + //////////////////////////////////////// + processStep.run(input, output); + + QRecord sharedSavedReportRecord = new GetAction().executeForRecord(new GetInput(SharedSavedReport.TABLE_NAME).withPrimaryKey(1)); + assertNotNull(sharedSavedReportRecord); + assertEquals(1, sharedSavedReportRecord.getValueInteger("savedReportId")); + assertEquals("darin@kingsrook.com", sharedSavedReportRecord.getValueString("userId")); + assertEquals(ShareScope.READ_WRITE.getPossibleValueId(), sharedSavedReportRecord.getValueString("scope")); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProviderTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProviderTest.java new file mode 100644 index 00000000..841b03dc --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/SharingMetaDataProviderTest.java @@ -0,0 +1,46 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.processes.implementations.sharing; + + +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.junit.jupiter.api.Test; + + +/******************************************************************************* + ** Unit test for SharingMetaDataProvider + *******************************************************************************/ +class SharingMetaDataProviderTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + new SharingMetaDataProvider().defineAll(QContext.getQInstance(), null); + } + +} \ No newline at end of file From 8a74609c5145e308d730c692bc1dce0883d40e37 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 Apr 2024 08:46:35 -0500 Subject: [PATCH 18/58] CE-882 Initial checkin - Meta-data for making a table shareable --- .../core/instances/QInstanceValidator.java | 5 + ...areScopePossibleValueMetaDataProducer.java | 48 +++ .../sharing/ShareableAudienceType.java | 186 +++++++++ .../sharing/ShareableTableMetaData.java | 356 ++++++++++++++++++ .../model/metadata/tables/QTableMetaData.java | 33 ++ .../instances/QInstanceValidatorTest.java | 19 +- .../sharing/ShareableTableMetaDataTest.java | 129 +++++++ 7 files changed, 774 insertions(+), 2 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareScopePossibleValueMetaDataProducer.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableAudienceType.java create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaDataTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java index e62725ec..3697108b 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java @@ -624,6 +624,11 @@ public class QInstanceValidator supplementalTableMetaData.validate(qInstance, table, this); } + if(table.getShareableTableMetaData() != null) + { + table.getShareableTableMetaData().validate(qInstance, table, this); + } + runPlugins(QTableMetaData.class, table, qInstance); }); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareScopePossibleValueMetaDataProducer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareScopePossibleValueMetaDataProducer.java new file mode 100644 index 00000000..49e5bb64 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareScopePossibleValueMetaDataProducer.java @@ -0,0 +1,48 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.metadata.sharing; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.MetaDataProducerInterface; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.processes.implementations.sharing.ShareScope; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class ShareScopePossibleValueMetaDataProducer implements MetaDataProducerInterface +{ + public static final String NAME = "shareScope"; + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public QPossibleValueSource produce(QInstance qInstance) throws QException + { + return QPossibleValueSource.newForEnum(NAME, ShareScope.values()); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableAudienceType.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableAudienceType.java new file mode 100644 index 00000000..2ab36ff3 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableAudienceType.java @@ -0,0 +1,186 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.metadata.sharing; + + +import java.io.Serializable; + + +/******************************************************************************* + ** As a component of a ShareableTableMetaData instance, define details about + ** one particular audience type. + ** + ** e.g., if a table can be shared to users and groups, there'd be 2 instances of + ** this object - one like: + ** - name: user + ** - fieldName: userId + ** - sourceTableName: User.TABLE_NAME + ** - sourceTableKeyFieldName: email (e.g., can be a UK, not just the PKey) + ** + ** and another similar, w/ the group-type details. + *******************************************************************************/ +public class ShareableAudienceType implements Serializable +{ + private String name; + private String fieldName; + private String sourceTableName; + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + // maybe normally the primary key in the source table, but could be a unique-key instead sometimes // + ///////////////////////////////////////////////////////////////////////////////////////////////////// + private String sourceTableKeyFieldName; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public ShareableAudienceType() + { + } + + + + /******************************************************************************* + ** Getter for name + *******************************************************************************/ + public String getName() + { + return (this.name); + } + + + + /******************************************************************************* + ** Setter for name + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** Fluent setter for name + *******************************************************************************/ + public ShareableAudienceType withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************* + ** Getter for fieldName + *******************************************************************************/ + public String getFieldName() + { + return (this.fieldName); + } + + + + /******************************************************************************* + ** Setter for fieldName + *******************************************************************************/ + public void setFieldName(String fieldName) + { + this.fieldName = fieldName; + } + + + + /******************************************************************************* + ** Fluent setter for fieldName + *******************************************************************************/ + public ShareableAudienceType withFieldName(String fieldName) + { + this.fieldName = fieldName; + return (this); + } + + + + /******************************************************************************* + ** Getter for sourceTableName + *******************************************************************************/ + public String getSourceTableName() + { + return (this.sourceTableName); + } + + + + /******************************************************************************* + ** Setter for sourceTableName + *******************************************************************************/ + public void setSourceTableName(String sourceTableName) + { + this.sourceTableName = sourceTableName; + } + + + + /******************************************************************************* + ** Fluent setter for sourceTableName + *******************************************************************************/ + public ShareableAudienceType withSourceTableName(String sourceTableName) + { + this.sourceTableName = sourceTableName; + return (this); + } + + + + /******************************************************************************* + ** Getter for sourceTableKeyFieldName + *******************************************************************************/ + public String getSourceTableKeyFieldName() + { + return (this.sourceTableKeyFieldName); + } + + + + /******************************************************************************* + ** Setter for sourceTableKeyFieldName + *******************************************************************************/ + public void setSourceTableKeyFieldName(String sourceTableKeyFieldName) + { + this.sourceTableKeyFieldName = sourceTableKeyFieldName; + } + + + + /******************************************************************************* + ** Fluent setter for sourceTableKeyFieldName + *******************************************************************************/ + public ShareableAudienceType withSourceTableKeyFieldName(String sourceTableKeyFieldName) + { + this.sourceTableKeyFieldName = sourceTableKeyFieldName; + return (this); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java new file mode 100644 index 00000000..a0193b63 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java @@ -0,0 +1,356 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.metadata.sharing; + + +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** meta data to attach to a table, to describe that its records are shareable. + *******************************************************************************/ +public class ShareableTableMetaData implements Serializable +{ + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this is the name of the table that is a many-to-one join to the table whose records are being shared. // + // not the table whose records are shared (the asset table) // + // for example: given that we want to share "savedReports", the value here could be "sharedSavedReports" // + // and this object will be attached to the savedReports table. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + private String sharedRecordTableName; + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // name of the field in the sharedRecordTable that has a foreign key pointing at the asset table // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + private String assetIdFieldName; + + ////////////////////////////////////////////////////// + // name of the scope field in the sharedRecordTable // + ////////////////////////////////////////////////////// + private String scopeFieldName; + + /////////////////////////////////////////////////////////// + // map of audienceTypes names to type definition objects // + /////////////////////////////////////////////////////////// + private Map audienceTypes; + + ///////////////////////////////////////////////// + // PVS that lists the available audience types // + ///////////////////////////////////////////////// + private String audienceTypesPossibleValueSourceName; + + ////////////////////////////////////////////////////////////// + // name of a field in "this" table, that has the owner's id // + ////////////////////////////////////////////////////////////// + private String thisTableOwnerIdFieldName; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public ShareableTableMetaData() + { + } + + + + /******************************************************************************* + ** Getter for sharedRecordTableName + *******************************************************************************/ + public String getSharedRecordTableName() + { + return (this.sharedRecordTableName); + } + + + + /******************************************************************************* + ** Setter for sharedRecordTableName + *******************************************************************************/ + public void setSharedRecordTableName(String sharedRecordTableName) + { + this.sharedRecordTableName = sharedRecordTableName; + } + + + + /******************************************************************************* + ** Fluent setter for sharedRecordTableName + *******************************************************************************/ + public ShareableTableMetaData withSharedRecordTableName(String sharedRecordTableName) + { + this.sharedRecordTableName = sharedRecordTableName; + return (this); + } + + + + /******************************************************************************* + ** Getter for assetIdFieldName + *******************************************************************************/ + public String getAssetIdFieldName() + { + return (this.assetIdFieldName); + } + + + + /******************************************************************************* + ** Setter for assetIdFieldName + *******************************************************************************/ + public void setAssetIdFieldName(String assetIdFieldName) + { + this.assetIdFieldName = assetIdFieldName; + } + + + + /******************************************************************************* + ** Fluent setter for assetIdFieldName + *******************************************************************************/ + public ShareableTableMetaData withAssetIdFieldName(String assetIdFieldName) + { + this.assetIdFieldName = assetIdFieldName; + return (this); + } + + + + /******************************************************************************* + ** Getter for scopeFieldName + *******************************************************************************/ + public String getScopeFieldName() + { + return (this.scopeFieldName); + } + + + + /******************************************************************************* + ** Setter for scopeFieldName + *******************************************************************************/ + public void setScopeFieldName(String scopeFieldName) + { + this.scopeFieldName = scopeFieldName; + } + + + + /******************************************************************************* + ** Fluent setter for scopeFieldName + *******************************************************************************/ + public ShareableTableMetaData withScopeFieldName(String scopeFieldName) + { + this.scopeFieldName = scopeFieldName; + return (this); + } + + + + /******************************************************************************* + ** Getter for audienceTypes + *******************************************************************************/ + public Map getAudienceTypes() + { + return (this.audienceTypes); + } + + + + /******************************************************************************* + ** Setter for audienceTypes + *******************************************************************************/ + public void setAudienceTypes(Map audienceTypes) + { + this.audienceTypes = audienceTypes; + } + + + + /******************************************************************************* + ** Fluent setter for audienceTypes + *******************************************************************************/ + public ShareableTableMetaData withAudienceTypes(Map audienceTypes) + { + this.audienceTypes = audienceTypes; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for audienceTypes + *******************************************************************************/ + public ShareableTableMetaData withAudienceType(ShareableAudienceType audienceType) + { + if(this.audienceTypes == null) + { + this.audienceTypes = new LinkedHashMap<>(); + } + + if(audienceType.getName() == null) + { + throw (new IllegalArgumentException("Attempt to add an audience type without a name")); + } + + if(this.audienceTypes.containsKey(audienceType.getName())) + { + throw (new IllegalArgumentException("Attempt to add more than 1 audience type with the same name [" + audienceType.getName() + "]")); + } + + this.audienceTypes.put(audienceType.getName(), audienceType); + + return (this); + } + + + + /******************************************************************************* + ** Getter for audienceTypesPossibleValueSourceName + *******************************************************************************/ + public String getAudienceTypesPossibleValueSourceName() + { + return (this.audienceTypesPossibleValueSourceName); + } + + + + /******************************************************************************* + ** Setter for audienceTypesPossibleValueSourceName + *******************************************************************************/ + public void setAudienceTypesPossibleValueSourceName(String audienceTypesPossibleValueSourceName) + { + this.audienceTypesPossibleValueSourceName = audienceTypesPossibleValueSourceName; + } + + + + /******************************************************************************* + ** Fluent setter for audienceTypesPossibleValueSourceName + *******************************************************************************/ + public ShareableTableMetaData withAudienceTypesPossibleValueSourceName(String audienceTypesPossibleValueSourceName) + { + this.audienceTypesPossibleValueSourceName = audienceTypesPossibleValueSourceName; + return (this); + } + + + + /******************************************************************************* + ** Getter for thisTableOwnerIdFieldName + *******************************************************************************/ + public String getThisTableOwnerIdFieldName() + { + return (this.thisTableOwnerIdFieldName); + } + + + + /******************************************************************************* + ** Setter for thisTableOwnerIdFieldName + *******************************************************************************/ + public void setThisTableOwnerIdFieldName(String thisTableOwnerIdFieldName) + { + this.thisTableOwnerIdFieldName = thisTableOwnerIdFieldName; + } + + + + /******************************************************************************* + ** Fluent setter for thisTableOwnerIdFieldName + *******************************************************************************/ + public ShareableTableMetaData withThisTableOwnerIdFieldName(String thisTableOwnerIdFieldName) + { + this.thisTableOwnerIdFieldName = thisTableOwnerIdFieldName; + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void validate(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator) + { + String prefix = "ShareableTableMetaData for table [" + tableMetaData.getName() + "]: "; + if(qInstanceValidator.assertCondition(StringUtils.hasContent(sharedRecordTableName), prefix + "missing sharedRecordTableName.")) + { + boolean hasAssetIdFieldName = qInstanceValidator.assertCondition(StringUtils.hasContent(assetIdFieldName), prefix + "missing assetIdFieldName"); + boolean hasScopeFieldName = qInstanceValidator.assertCondition(StringUtils.hasContent(scopeFieldName), prefix + "missing scopeFieldName"); + + QTableMetaData sharedRecordTable = qInstance.getTable(sharedRecordTableName); + boolean hasValidSharedRecordTable = qInstanceValidator.assertCondition(sharedRecordTable != null, prefix + "unrecognized sharedRecordTableName [" + sharedRecordTableName + "]"); + + if(hasValidSharedRecordTable && hasAssetIdFieldName) + { + qInstanceValidator.assertCondition(sharedRecordTable.getFields().containsKey(assetIdFieldName), prefix + "unrecognized assertIdFieldName [" + assetIdFieldName + "] in sharedRecordTable [" + sharedRecordTableName + "]"); + } + + if(hasValidSharedRecordTable && hasScopeFieldName) + { + qInstanceValidator.assertCondition(sharedRecordTable.getFields().containsKey(scopeFieldName), prefix + "unrecognized scopeFieldName [" + scopeFieldName + "] in sharedRecordTable [" + sharedRecordTableName + "]"); + } + + if(qInstanceValidator.assertCondition(CollectionUtils.nullSafeHasContents(audienceTypes), prefix + "missing audienceTypes")) + { + for(Map.Entry entry : audienceTypes.entrySet()) + { + ShareableAudienceType audienceType = entry.getValue(); + qInstanceValidator.assertCondition(Objects.equals(entry.getKey(), audienceType.getName()), prefix + "inconsistent naming for shareableAudienceType [" + entry.getKey() + "] != [" + audienceType.getName() + "]"); + if(qInstanceValidator.assertCondition(StringUtils.hasContent(audienceType.getFieldName()), prefix + "missing fieldName for shareableAudienceType [" + entry.getKey() + "]") && hasValidSharedRecordTable) + { + qInstanceValidator.assertCondition(sharedRecordTable.getFields().containsKey(audienceType.getFieldName()), prefix + "unrecognized fieldName [" + audienceType.getFieldName() + "] for shareableAudienceType [" + entry.getKey() + "] in sharedRecordTable [" + sharedRecordTableName + "]"); + } + + /* todo - make these optional i guess, because i didn't put user table in qqq + boolean hasSourceTableKeyFieldName = qInstanceValidator.assertCondition(StringUtils.hasContent(audienceType.getSourceTableKeyFieldName()), prefix + "missing sourceTableKeyFieldName for shareableAudienceType [" + entry.getKey() + "]"); + if(qInstanceValidator.assertCondition(qInstance.getTable(audienceType.getSourceTableName()) != null, prefix + "unrecognized sourceTableName [" + audienceType.getSourceTableName() + "] for shareableAudienceType [" + entry.getKey() + "] in sharedRecordTable [" + sharedRecordTableName + "]") && hasSourceTableKeyFieldName) + { + qInstanceValidator.assertCondition(qInstance.getTable(audienceType.getSourceTableName()).getFields().containsKey(audienceType.getSourceTableKeyFieldName()), prefix + "unrecognized sourceTableKeyFieldName [" + audienceType.getSourceTableKeyFieldName() + "] for shareableAudienceType [" + entry.getKey() + "] in sharedRecordTable [" + sharedRecordTableName + "]"); + } + */ + } + } + } + + if(StringUtils.hasContent(thisTableOwnerIdFieldName)) + { + qInstanceValidator.assertCondition(tableMetaData.getFields().containsKey(thisTableOwnerIdFieldName), prefix + "unrecognized thisTableOwnerIdFieldName [" + thisTableOwnerIdFieldName + "]"); + } + + if(StringUtils.hasContent(audienceTypesPossibleValueSourceName)) + { + qInstanceValidator.assertCondition(qInstance.getPossibleValueSource(audienceTypesPossibleValueSourceName) != null, prefix + "unrecognized audienceTypesPossibleValueSourceName [" + audienceTypesPossibleValueSourceName + "]"); + } + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java index fac05aaa..a14fc566 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java @@ -48,6 +48,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.automation.QTableAutomationDetails; import com.kingsrook.qqq.backend.core.model.metadata.tables.cache.CacheOf; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -107,6 +108,7 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData private List exposedJoins; + private ShareableTableMetaData shareableTableMetaData; /******************************************************************************* @@ -1385,4 +1387,35 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData return (this); } + + /******************************************************************************* + ** Getter for shareableTableMetaData + *******************************************************************************/ + public ShareableTableMetaData getShareableTableMetaData() + { + return (this.shareableTableMetaData); + } + + + + /******************************************************************************* + ** Setter for shareableTableMetaData + *******************************************************************************/ + public void setShareableTableMetaData(ShareableTableMetaData shareableTableMetaData) + { + this.shareableTableMetaData = shareableTableMetaData; + } + + + + /******************************************************************************* + ** Fluent setter for shareableTableMetaData + *******************************************************************************/ + public QTableMetaData withShareableTableMetaData(ShareableTableMetaData shareableTableMetaData) + { + this.shareableTableMetaData = shareableTableMetaData; + return (this); + } + + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java index a9b490ad..1c6947a3 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidatorTest.java @@ -78,6 +78,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaDa import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock; 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.sharing.ShareableTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; @@ -2004,7 +2005,21 @@ public class QInstanceValidatorTest extends BaseTest qInstance.addTable(newTable("B", "id", "aId")); qInstance.addJoin(new QJoinMetaData().withLeftTable("A").withRightTable("B").withName("AB").withType(JoinType.ONE_TO_ONE).withJoinOn(new JoinOn("id", "aId"))); }); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testShareableTableMetaData() + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // just make sure we call this class's validator - the rest of its conditions are covered in its own test // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable("A", "id").withShareableTableMetaData(new ShareableTableMetaData())), + "missing sharedRecordTableName"); } @@ -2113,7 +2128,7 @@ public class QInstanceValidatorTest extends BaseTest /******************************************************************************* ** *******************************************************************************/ - private QTableMetaData newTable(String tableName, String... fieldNames) + protected QTableMetaData newTable(String tableName, String... fieldNames) { QTableMetaData tableMetaData = new QTableMetaData() .withName(tableName) @@ -2207,7 +2222,7 @@ public class QInstanceValidatorTest extends BaseTest /******************************************************************************* ** Assert that an instance is valid! *******************************************************************************/ - private void assertValidationSuccess(Consumer setup) + public static void assertValidationSuccess(Consumer setup) { try { diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaDataTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaDataTest.java new file mode 100644 index 00000000..23e5c4a7 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaDataTest.java @@ -0,0 +1,129 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.metadata.sharing; + + +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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.TestUtils; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.backend.core.instances.QInstanceValidatorTest.assertValidationFailureReasons; +import static com.kingsrook.qqq.backend.core.instances.QInstanceValidatorTest.assertValidationFailureReasonsAllowingExtraReasons; +import static com.kingsrook.qqq.backend.core.instances.QInstanceValidatorTest.assertValidationSuccess; + + +/******************************************************************************* + ** Unit test for ShareableTableMetaData + *******************************************************************************/ +class ShareableTableMetaDataTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidation() + { + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData())), + "missing sharedRecordTableName"); + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName("notATable") + )), "unrecognized sharedRecordTableName"); + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withAudienceTypesPossibleValueSourceName("notAPVS") + )), "unrecognized audienceTypesPossibleValueSourceName"); + + assertValidationFailureReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + )), "missing assetIdFieldName", + "missing scopeFieldName", + "missing audienceTypes"); + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withAssetIdFieldName("notAField") + )), "unrecognized assertIdFieldName"); + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withScopeFieldName("notAField") + )), "unrecognized scopeFieldName"); + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withAudienceType(new ShareableAudienceType().withName("myType")) + )), "missing fieldName for shareableAudienceType"); + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withAudienceType(new ShareableAudienceType().withName("myType").withFieldName("notAField")) + )), "unrecognized fieldName"); + + /* todo - corresponding todo in main class + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withAudienceType(new ShareableAudienceType().withName("myType").withFieldName("firstName").withSourceTableName("notATable")) + )), "unrecognized sourceTableName"); + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withAudienceType(new ShareableAudienceType().withName("myType").withFieldName("firstName").withSourceTableName(TestUtils.TABLE_NAME_SHAPE).withSourceTableKeyFieldName("notAField")) + )), "unrecognized sourceTableKeyFieldName"); + */ + + assertValidationFailureReasonsAllowingExtraReasons(qInstance -> qInstance.addTable(newTable().withShareableTableMetaData(new ShareableTableMetaData() + .withThisTableOwnerIdFieldName("notAField") + )), "unrecognized thisTableOwnerIdFieldName"); + + assertValidationSuccess(qInstance -> qInstance.addTable(newTable() + .withField(new QFieldMetaData("userId", QFieldType.INTEGER)) + .withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(TestUtils.TABLE_NAME_PERSON_MEMORY) + .withAssetIdFieldName("firstName") + .withScopeFieldName("firstName") + .withThisTableOwnerIdFieldName("userId") + .withAudienceTypesPossibleValueSourceName(TestUtils.POSSIBLE_VALUE_SOURCE_STATE) + .withAudienceType(new ShareableAudienceType().withName("myType").withFieldName("lastName").withSourceTableName(TestUtils.TABLE_NAME_SHAPE).withSourceTableKeyFieldName("id")) + ))); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + protected QTableMetaData newTable() + { + QTableMetaData tableMetaData = new QTableMetaData() + .withName("A") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withPrimaryKeyField("id"); + + tableMetaData.addField(new QFieldMetaData("id", QFieldType.INTEGER)); + + return (tableMetaData); + } + +} \ No newline at end of file From 97b17085be7de09a8070dbb1f1809c167852c6a7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 Apr 2024 08:47:04 -0500 Subject: [PATCH 19/58] CE-882 in case field doesn't have label, use fieldName in some cases (helps w/ tests that don't enrich) --- .../qqq/backend/core/actions/audits/DMLAuditAction.java | 2 +- .../kingsrook/qqq/backend/core/actions/tables/DeleteAction.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java index 044a24b7..a1183ba4 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditAction.java @@ -149,7 +149,7 @@ public class DMLAuditAction extends AbstractQActionFunction sortedFieldNames = table.getFields().keySet().stream() - .sorted(Comparator.comparing(fieldName -> table.getFields().get(fieldName).getLabel())) + .sorted(Comparator.comparing(fieldName -> Objects.requireNonNullElse(table.getFields().get(fieldName).getLabel(), fieldName))) .toList(); QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField()); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java index 05cc83b1..f964b62e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java @@ -470,7 +470,7 @@ public class DeleteAction QRecord recordWithError = new QRecord(); recordsWithErrors.add(recordWithError); recordWithError.setValue(primaryKeyField.getName(), primaryKeyValue); - recordWithError.addError(new NotFoundStatusMessage("No record was found to delete for " + primaryKeyField.getLabel() + " = " + primaryKeyValue)); + recordWithError.addError(new NotFoundStatusMessage("No record was found to delete for " + Objects.requireNonNullElse(primaryKeyField.getLabel(), primaryKeyField.getName()) + " = " + primaryKeyValue)); } } } From 68687a5d3bc8ffbeba230c5310932f4150994bac Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 Apr 2024 08:48:54 -0500 Subject: [PATCH 20/58] CE-882 Add shared reports table, e.g., sharing of reports. --- .../SavedReportsMetaDataProvider.java | 51 ++++ .../model/savedreports/SharedSavedReport.java | 267 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SharedSavedReport.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java index b82e648f..caed5e6f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java @@ -42,10 +42,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareScopePossibleValueMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableAudienceType; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; +import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; import com.kingsrook.qqq.backend.core.processes.implementations.savedreports.RenderSavedReportMetaDataProducer; @@ -80,6 +84,15 @@ public class SavedReportsMetaDataProvider instance.addWidget(defineReportSetupWidget()); instance.addWidget(definePivotTableSetupWidget()); + + ///////////////////////////////////// + // todo - param to enable sharing? // + ///////////////////////////////////// + instance.addTable(defineSharedSavedReportTable(recordTablesBackendName, backendDetailEnricher)); + if(instance.getPossibleValueSource(ShareScopePossibleValueMetaDataProducer.NAME) == null) + { + instance.addPossibleValueSource(new ShareScopePossibleValueMetaDataProducer().produce(new QInstance())); + } } @@ -165,6 +178,44 @@ public class SavedReportsMetaDataProvider table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(SavedReportTableCustomizer.class)); table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(SavedReportTableCustomizer.class)); + table.withShareableTableMetaData(new ShareableTableMetaData() + .withSharedRecordTableName(SharedSavedReport.TABLE_NAME) + .withAssetIdFieldName("savedReportId") + .withScopeFieldName("scope") + .withThisTableOwnerIdFieldName("userId") + .withAudienceType(new ShareableAudienceType().withName("user").withFieldName("userId"))); + + if(backendDetailEnricher != null) + { + backendDetailEnricher.accept(table); + } + + return (table); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public QTableMetaData defineSharedSavedReportTable(String backendName, Consumer backendDetailEnricher) throws QException + { + QTableMetaData table = new QTableMetaData() + .withName(SharedSavedReport.TABLE_NAME) + .withLabel("Shared Report") + .withIcon(new QIcon().withName("share")) + .withRecordLabelFormat("%s") + .withRecordLabelFields("savedReportId") + .withBackendName(backendName) + .withUniqueKey(new UniqueKey("savedReportId", "userId")) + .withPrimaryKeyField("id") + .withFieldsFromEntity(SharedSavedReport.class) + // todo - security key + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) + .withSection(new QFieldSection("identity", new QIcon().withName("badge"), Tier.T1, List.of("id", "savedReportId", "userId"))) + .withSection(new QFieldSection("data", new QIcon().withName("text_snippet"), Tier.T2, List.of("scope"))) + .withSection(new QFieldSection("dates", new QIcon().withName("calendar_month"), Tier.T3, List.of("createDate", "modifyDate"))); + if(backendDetailEnricher != null) { backendDetailEnricher.accept(table); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SharedSavedReport.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SharedSavedReport.java new file mode 100644 index 00000000..37ac78a5 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SharedSavedReport.java @@ -0,0 +1,267 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.savedreports; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; +import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareScopePossibleValueMetaDataProducer; + + +/******************************************************************************* + ** Entity bean for the shared saved report table + *******************************************************************************/ +public class SharedSavedReport extends QRecordEntity +{ + public static final String TABLE_NAME = "sharedSavedReport"; + + @QField(isEditable = false) + private Integer id; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + @QField(possibleValueSourceName = SavedReport.TABLE_NAME, label = "Report") + private Integer savedReportId; + + @QField(label = "User") + private String userId; + + @QField(possibleValueSourceName = ShareScopePossibleValueMetaDataProducer.NAME) + private String scope; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public SharedSavedReport() + { + } + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public SharedSavedReport(QRecord qRecord) throws QException + { + populateFromQRecord(qRecord); + } + + + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public SharedSavedReport withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public SharedSavedReport withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public SharedSavedReport withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for savedReportId + *******************************************************************************/ + public Integer getSavedReportId() + { + return (this.savedReportId); + } + + + + /******************************************************************************* + ** Setter for savedReportId + *******************************************************************************/ + public void setSavedReportId(Integer savedReportId) + { + this.savedReportId = savedReportId; + } + + + + /******************************************************************************* + ** Fluent setter for savedReportId + *******************************************************************************/ + public SharedSavedReport withSavedReportId(Integer savedReportId) + { + this.savedReportId = savedReportId; + return (this); + } + + + + /******************************************************************************* + ** Getter for userId + *******************************************************************************/ + public String getUserId() + { + return (this.userId); + } + + + + /******************************************************************************* + ** Setter for userId + *******************************************************************************/ + public void setUserId(String userId) + { + this.userId = userId; + } + + + + /******************************************************************************* + ** Fluent setter for userId + *******************************************************************************/ + public SharedSavedReport withUserId(String userId) + { + this.userId = userId; + return (this); + } + + + + /******************************************************************************* + ** Getter for scope + *******************************************************************************/ + public String getScope() + { + return (this.scope); + } + + + + /******************************************************************************* + ** Setter for scope + *******************************************************************************/ + public void setScope(String scope) + { + this.scope = scope; + } + + + + /******************************************************************************* + ** Fluent setter for scope + *******************************************************************************/ + public SharedSavedReport withScope(String scope) + { + this.scope = scope; + return (this); + } + + +} From b692f1a60d21041debe10401829a8cc133d3ed0c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 Apr 2024 08:58:01 -0500 Subject: [PATCH 21/58] CE-882 Fixed expected field name (for the id...) --- .../implementations/sharing/GetSharedRecordsProcessTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java index 9a7fdb63..d00e1898 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcessTest.java @@ -88,7 +88,7 @@ class GetSharedRecordsProcessTest extends BaseTest assertEquals(1, resultList.size()); QRecord outputRecord = resultList.get(0); - assertEquals(1, outputRecord.getValueInteger("id")); + assertEquals(1, outputRecord.getValueInteger("shareId")); assertEquals(ShareScope.READ_WRITE.getPossibleValueId(), outputRecord.getValueString("scopeId")); assertEquals("user", outputRecord.getValueString("audienceType")); assertEquals("007", outputRecord.getValueString("audienceId")); From 659c95a85be6bbdd6c9e086ad1a544298ef6819f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 24 Apr 2024 12:49:29 -0500 Subject: [PATCH 22/58] pin a static version of qfmd --- qqq-sample-project/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qqq-sample-project/pom.xml b/qqq-sample-project/pom.xml index 11c70b15..9d9ea946 100644 --- a/qqq-sample-project/pom.xml +++ b/qqq-sample-project/pom.xml @@ -68,7 +68,7 @@ com.kingsrook.qqq qqq-frontend-material-dashboard - ${revision} + 0.20.0-20240418.180316-42 com.h2database From c6e0389338366e8d09cd5bc059c5e95b69fefb4b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 19:59:48 -0500 Subject: [PATCH 23/58] entities, meta-data, and test for sharing POC --- .../sharing/SharingMetaDataProvider.java | 117 ++++++++ .../module/rdbms/sharing/SharingTest.java | 149 ++++++++++ .../module/rdbms/sharing/model/Asset.java | 227 +++++++++++++++ .../rdbms/sharing/model/AssetAudienceInt.java | 226 +++++++++++++++ .../module/rdbms/sharing/model/Audience.java | 261 ++++++++++++++++++ .../module/rdbms/sharing/model/Client.java | 192 +++++++++++++ .../module/rdbms/sharing/model/Group.java | 226 +++++++++++++++ .../module/rdbms/sharing/model/User.java | 193 +++++++++++++ .../prime-test-database-sharing-test.sql | 85 ++++++ 9 files changed, 1676 insertions(+) create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Asset.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Audience.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Client.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Group.java create mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/User.java create mode 100644 qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java new file mode 100644 index 00000000..a961fc58 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java @@ -0,0 +1,117 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.rdbms.TestUtils; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Asset; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.AssetAudienceInt; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Audience; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Client; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Group; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.User; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class SharingMetaDataProvider +{ + public static final String USER_ID_KEY_TYPE = "userIdKey"; + public static final String USER_ID_ALL_ACCESS_KEY_TYPE = "userIdAllAccessKey"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static void defineAll(QInstance qInstance) throws QException + { + qInstance.addSecurityKeyType(new QSecurityKeyType() + .withName(USER_ID_KEY_TYPE) + .withAllAccessKeyName(USER_ID_ALL_ACCESS_KEY_TYPE)); + + qInstance.addTable(new QTableMetaData() + .withName(Asset.TABLE_NAME) + .withPrimaryKeyField("id") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withFieldsFromEntity(Asset.class) + .withRecordSecurityLock(new RecordSecurityLock() + .withSecurityKeyType(USER_ID_KEY_TYPE) + .withFieldName("userId"))); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Asset.TABLE_NAME)); + + qInstance.addTable(new QTableMetaData() + .withName(Audience.TABLE_NAME) + .withPrimaryKeyField("id") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withFieldsFromEntity(Audience.class)); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Audience.TABLE_NAME)); + + qInstance.addTable(new QTableMetaData() + .withName(AssetAudienceInt.TABLE_NAME) + .withBackendDetails(new RDBMSTableBackendDetails().withTableName("asset_audience_int")) + .withPrimaryKeyField("id") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withFieldsFromEntity(AssetAudienceInt.class)); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(AssetAudienceInt.TABLE_NAME)); + + qInstance.addTable(new QTableMetaData() + .withName(User.TABLE_NAME) + .withPrimaryKeyField("id") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withFieldsFromEntity(User.class) + .withRecordSecurityLock(new RecordSecurityLock() + .withSecurityKeyType(USER_ID_KEY_TYPE) + .withFieldName("id"))); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(User.TABLE_NAME)); + + qInstance.addTable(new QTableMetaData() + .withName(Group.TABLE_NAME) + .withPrimaryKeyField("id") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withFieldsFromEntity(Group.class)); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Group.TABLE_NAME)); + + qInstance.addTable(new QTableMetaData() + .withName(Client.TABLE_NAME) + .withPrimaryKeyField("id") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withFieldsFromEntity(Client.class)); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Client.TABLE_NAME)); + + qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(User.TABLE_NAME)); + qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Group.TABLE_NAME)); + qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Client.TABLE_NAME)); + qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Asset.TABLE_NAME)); + qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Audience.TABLE_NAME)); + } + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java new file mode 100644 index 00000000..38154d51 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java @@ -0,0 +1,149 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing; + + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +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.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.module.rdbms.TestUtils; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Asset; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.AssetAudienceInt; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Audience; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Group; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class SharingTest +{ + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws Exception + { + TestUtils.primeTestDatabase("prime-test-database-sharing-test.sql"); + + QInstance qInstance = TestUtils.defineInstance(); + SharingMetaDataProvider.defineAll(qInstance); + + QContext.init(qInstance, new QSession()); + + loadData(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void loadData() throws QException + { + QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_ALL_ACCESS_KEY_TYPE, true); + + List userList = List.of( + new User().withId(100).withUsername("homer"), + new User().withId(101).withUsername("marge"), + new User().withId(102).withUsername("bart"), + new User().withId(103).withUsername("lisa"), + new User().withId(110).withUsername("burns")); + new InsertAction().execute(new InsertInput(User.TABLE_NAME).withRecordEntities(userList)); + + List groupList = List.of( + new Group().withId(200).withName("simpsons"), + new Group().withId(201).withName("powerplant")); + new InsertAction().execute(new InsertInput(Group.TABLE_NAME).withRecordEntities(groupList)); + + List assetList = List.of( + new Asset().withId(3000).withName("742evergreen").withUserId(100), + new Asset().withId(3001).withName("beer").withUserId(100), + new Asset().withId(3010).withName("bed").withUserId(101), + new Asset().withId(3020).withName("skateboard").withUserId(102), + new Asset().withId(3030).withName("saxamaphone").withUserId(103)); + new InsertAction().execute(new InsertInput(Asset.TABLE_NAME).withRecordEntities(assetList)); + + List assetAudienceIntList = List.of( + // homer shares his house with the simpson family (group) + new AssetAudienceInt().withAssetId(3000).withAudienceId(200), + + // marge shares a bed with homer + new AssetAudienceInt().withAssetId(3010).withAudienceId(100) + + ); + new InsertAction().execute(new InsertInput(AssetAudienceInt.TABLE_NAME).withRecordEntities(assetAudienceIntList)); + + List audienceList = new ArrayList<>(); + for(QRecordEntity entity : userList) + { + User user = (User) entity; + audienceList.add(new Audience().withId(user.getId()).withName(user.getUsername()).withType("user")); + } + for(QRecordEntity entity : groupList) + { + Group group = (Group) entity; + audienceList.add(new Audience().withId(group.getId()).withName(group.getName()).withType("group")); + } + new InsertAction().execute(new InsertInput(Audience.TABLE_NAME).withRecordEntities(audienceList)); + + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() throws QException + { + assertEquals(0, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 101); + assertEquals(1, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 100); + assertEquals(2, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 100); + QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 101); + assertEquals(3, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + } + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Asset.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Asset.java new file mode 100644 index 00000000..b911314d --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Asset.java @@ -0,0 +1,227 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; + + +/******************************************************************************* + ** QRecord Entity for Asset table + *******************************************************************************/ +public class Asset extends QRecordEntity +{ + public static final String TABLE_NAME = "Asset"; + + @QField(isEditable = false) + private Integer id; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + @QField() + private String name; + + @QField(possibleValueSourceName = User.TABLE_NAME) + private Integer userId; + + + + /******************************************************************************* + ** Default constructor + *******************************************************************************/ + public Asset() + { + } + + + + /******************************************************************************* + ** Constructor that takes a QRecord + *******************************************************************************/ + public Asset(QRecord record) + { + populateFromQRecord(record); + } + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public Asset withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public Asset withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public Asset withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for name + *******************************************************************************/ + public String getName() + { + return (this.name); + } + + + + /******************************************************************************* + ** Setter for name + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** Fluent setter for name + *******************************************************************************/ + public Asset withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************* + ** Getter for userId + *******************************************************************************/ + public Integer getUserId() + { + return (this.userId); + } + + + + /******************************************************************************* + ** Setter for userId + *******************************************************************************/ + public void setUserId(Integer userId) + { + this.userId = userId; + } + + + + /******************************************************************************* + ** Fluent setter for userId + *******************************************************************************/ + public Asset withUserId(Integer userId) + { + this.userId = userId; + return (this); + } + + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java new file mode 100644 index 00000000..212ca1a5 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java @@ -0,0 +1,226 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; + + +/******************************************************************************* + ** QRecord Entity for AssetAudienceInt table + *******************************************************************************/ +public class AssetAudienceInt extends QRecordEntity +{ + public static final String TABLE_NAME = "AssetAudienceInt"; + + @QField(isEditable = false) + private Integer id; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + @QField(possibleValueSourceName = Asset.TABLE_NAME) + private Integer assetId; + + @QField(possibleValueSourceName = Audience.TABLE_NAME) + private Integer audienceId; + + + /******************************************************************************* + ** Default constructor + *******************************************************************************/ + public AssetAudienceInt() + { + } + + + + /******************************************************************************* + ** Constructor that takes a QRecord + *******************************************************************************/ + public AssetAudienceInt(QRecord record) + { + populateFromQRecord(record); + } + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public AssetAudienceInt withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public AssetAudienceInt withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public AssetAudienceInt withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for assetId + *******************************************************************************/ + public Integer getAssetId() + { + return (this.assetId); + } + + + + /******************************************************************************* + ** Setter for assetId + *******************************************************************************/ + public void setAssetId(Integer assetId) + { + this.assetId = assetId; + } + + + + /******************************************************************************* + ** Fluent setter for assetId + *******************************************************************************/ + public AssetAudienceInt withAssetId(Integer assetId) + { + this.assetId = assetId; + return (this); + } + + + + /******************************************************************************* + ** Getter for audienceId + *******************************************************************************/ + public Integer getAudienceId() + { + return (this.audienceId); + } + + + + /******************************************************************************* + ** Setter for audienceId + *******************************************************************************/ + public void setAudienceId(Integer audienceId) + { + this.audienceId = audienceId; + } + + + + /******************************************************************************* + ** Fluent setter for audienceId + *******************************************************************************/ + public AssetAudienceInt withAudienceId(Integer audienceId) + { + this.audienceId = audienceId; + return (this); + } + + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Audience.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Audience.java new file mode 100644 index 00000000..88216a46 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Audience.java @@ -0,0 +1,261 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; + + +/******************************************************************************* + ** QRecord Entity for Audience table + *******************************************************************************/ +public class Audience extends QRecordEntity +{ + public static final String TABLE_NAME = "Audience"; + + @QField(isEditable = false) + private Integer id; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + @QField() + private String type; + + @QField() + private String name; + + @QField() + private String securityKey; + + + + /******************************************************************************* + ** Default constructor + *******************************************************************************/ + public Audience() + { + } + + + + /******************************************************************************* + ** Constructor that takes a QRecord + *******************************************************************************/ + public Audience(QRecord record) + { + populateFromQRecord(record); + } + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public Audience withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public Audience withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public Audience withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for type + *******************************************************************************/ + public String getType() + { + return (this.type); + } + + + + /******************************************************************************* + ** Setter for type + *******************************************************************************/ + public void setType(String type) + { + this.type = type; + } + + + + /******************************************************************************* + ** Fluent setter for type + *******************************************************************************/ + public Audience withType(String type) + { + this.type = type; + return (this); + } + + + + /******************************************************************************* + ** Getter for name + *******************************************************************************/ + public String getName() + { + return (this.name); + } + + + + /******************************************************************************* + ** Setter for name + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** Fluent setter for name + *******************************************************************************/ + public Audience withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************* + ** Getter for securityKey + *******************************************************************************/ + public String getSecurityKey() + { + return (this.securityKey); + } + + + + /******************************************************************************* + ** Setter for securityKey + *******************************************************************************/ + public void setSecurityKey(String securityKey) + { + this.securityKey = securityKey; + } + + + + /******************************************************************************* + ** Fluent setter for securityKey + *******************************************************************************/ + public Audience withSecurityKey(String securityKey) + { + this.securityKey = securityKey; + return (this); + } + + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Client.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Client.java new file mode 100644 index 00000000..01fd6827 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Client.java @@ -0,0 +1,192 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; + + +/******************************************************************************* + ** QRecord Entity for Client table + *******************************************************************************/ +public class Client extends QRecordEntity +{ + public static final String TABLE_NAME = "Client"; + + @QField(isEditable = false) + private Integer id; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + @QField() + private String name; + + + /******************************************************************************* + ** Default constructor + *******************************************************************************/ + public Client() + { + } + + + + /******************************************************************************* + ** Constructor that takes a QRecord + *******************************************************************************/ + public Client(QRecord record) + { + populateFromQRecord(record); + } + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public Client withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public Client withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public Client withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for name + *******************************************************************************/ + public String getName() + { + return (this.name); + } + + + + /******************************************************************************* + ** Setter for name + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** Fluent setter for name + *******************************************************************************/ + public Client withName(String name) + { + this.name = name; + return (this); + } + + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Group.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Group.java new file mode 100644 index 00000000..4c4ac65b --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Group.java @@ -0,0 +1,226 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; + + +/******************************************************************************* + ** QRecord Entity for Group table + *******************************************************************************/ +public class Group extends QRecordEntity +{ + public static final String TABLE_NAME = "Group"; + + @QField(isEditable = false) + private Integer id; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + @QField() + private String name; + + @QField(possibleValueSourceName = Client.TABLE_NAME) + private Integer clientId; + + + /******************************************************************************* + ** Default constructor + *******************************************************************************/ + public Group() + { + } + + + + /******************************************************************************* + ** Constructor that takes a QRecord + *******************************************************************************/ + public Group(QRecord record) + { + populateFromQRecord(record); + } + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public Group withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public Group withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public Group withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for name + *******************************************************************************/ + public String getName() + { + return (this.name); + } + + + + /******************************************************************************* + ** Setter for name + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** Fluent setter for name + *******************************************************************************/ + public Group withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************* + ** Getter for clientId + *******************************************************************************/ + public Integer getClientId() + { + return (this.clientId); + } + + + + /******************************************************************************* + ** Setter for clientId + *******************************************************************************/ + public void setClientId(Integer clientId) + { + this.clientId = clientId; + } + + + + /******************************************************************************* + ** Fluent setter for clientId + *******************************************************************************/ + public Group withClientId(Integer clientId) + { + this.clientId = clientId; + return (this); + } + + +} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/User.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/User.java new file mode 100644 index 00000000..06de5745 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/User.java @@ -0,0 +1,193 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.sharing.model; + + +import java.time.Instant; +import com.kingsrook.qqq.backend.core.model.data.QField; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; + + +/******************************************************************************* + ** QRecord Entity for User table + *******************************************************************************/ +public class User extends QRecordEntity +{ + public static final String TABLE_NAME = "User"; + + @QField(isEditable = false) + private Integer id; + + @QField(isEditable = false) + private Instant createDate; + + @QField(isEditable = false) + private Instant modifyDate; + + @QField() + private String username; + + + + /******************************************************************************* + ** Default constructor + *******************************************************************************/ + public User() + { + } + + + + /******************************************************************************* + ** Constructor that takes a QRecord + *******************************************************************************/ + public User(QRecord record) + { + populateFromQRecord(record); + } + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public User withId(Integer id) + { + this.id = id; + return (this); + } + + + + /******************************************************************************* + ** Getter for createDate + *******************************************************************************/ + public Instant getCreateDate() + { + return (this.createDate); + } + + + + /******************************************************************************* + ** Setter for createDate + *******************************************************************************/ + public void setCreateDate(Instant createDate) + { + this.createDate = createDate; + } + + + + /******************************************************************************* + ** Fluent setter for createDate + *******************************************************************************/ + public User withCreateDate(Instant createDate) + { + this.createDate = createDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for modifyDate + *******************************************************************************/ + public Instant getModifyDate() + { + return (this.modifyDate); + } + + + + /******************************************************************************* + ** Setter for modifyDate + *******************************************************************************/ + public void setModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + } + + + + /******************************************************************************* + ** Fluent setter for modifyDate + *******************************************************************************/ + public User withModifyDate(Instant modifyDate) + { + this.modifyDate = modifyDate; + return (this); + } + + + + /******************************************************************************* + ** Getter for username + *******************************************************************************/ + public String getUsername() + { + return (this.username); + } + + + + /******************************************************************************* + ** Setter for username + *******************************************************************************/ + public void setUsername(String username) + { + this.username = username; + } + + + + /******************************************************************************* + ** Fluent setter for username + *******************************************************************************/ + public User withUsername(String username) + { + this.username = username; + return (this); + } + + +} diff --git a/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql b/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql new file mode 100644 index 00000000..3c3e47d0 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql @@ -0,0 +1,85 @@ +-- +-- 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 . +-- + +DROP TABLE IF EXISTS `user`; +CREATE TABLE `user` +( + id INTEGER AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT now(), + modify_date TIMESTAMP DEFAULT now(), + username VARCHAR(100) +); + + +DROP TABLE IF EXISTS `group`; +CREATE TABLE `group` +( + id INTEGER AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT now(), + modify_date TIMESTAMP DEFAULT now(), + name VARCHAR(100), + client_id INTEGER +); + + +DROP TABLE IF EXISTS `client`; +CREATE TABLE `client` +( + id INTEGER AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT now(), + modify_date TIMESTAMP DEFAULT now(), + name VARCHAR(100) +); + + +DROP TABLE IF EXISTS audience; +CREATE TABLE audience +( + id INT AUTO_INCREMENT primary key , + create_date TIMESTAMP DEFAULT now(), + modify_date TIMESTAMP DEFAULT now(), + type VARCHAR(50), + name VARCHAR(100), + security_key VARCHAR(100) +); + + +DROP TABLE IF EXISTS asset; +CREATE TABLE asset +( + id INT AUTO_INCREMENT primary key , + create_date TIMESTAMP DEFAULT now(), + modify_date TIMESTAMP DEFAULT now(), + name VARCHAR(100), + user_id INTEGER +); + + +DROP TABLE IF EXISTS asset_audience_int; +CREATE TABLE asset_audience_int +( + id INT AUTO_INCREMENT primary key , + create_date TIMESTAMP DEFAULT now(), + modify_date TIMESTAMP DEFAULT now(), + asset_id INTEGER, + audience_id INTEGER +); + From ef2b08899b32a16e60a10b4458a13738d8ff5462 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:01:08 -0500 Subject: [PATCH 24/58] CE-882 Add TRUE and FALSE operators --- .../tables/query/QCriteriaOperator.java | 4 ++- .../utils/BackendQueryFilterUtils.java | 2 ++ .../memory/MemoryBackendModuleTest.java | 3 ++ .../actions/AbstractMongoDBAction.java | 2 ++ .../actions/MongoDBQueryActionTest.java | 27 ++++++++++++++++++ .../rdbms/actions/AbstractRDBMSAction.java | 13 +++++++-- .../rdbms/actions/RDBMSQueryActionTest.java | 28 +++++++++++++++++++ 7 files changed, 75 insertions(+), 4 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QCriteriaOperator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QCriteriaOperator.java index 6dc50b1d..99498007 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QCriteriaOperator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QCriteriaOperator.java @@ -49,5 +49,7 @@ public enum QCriteriaOperator IS_BLANK, IS_NOT_BLANK, BETWEEN, - NOT_BETWEEN + NOT_BETWEEN, + TRUE, + FALSE } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java index a68b61a5..e8b09615 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/utils/BackendQueryFilterUtils.java @@ -179,6 +179,8 @@ public class BackendQueryFilterUtils boolean between = (testGreaterThan(criteria0, value) || testEquals(criteria0, value)) && (!testGreaterThan(criteria1, value) || testEquals(criteria1, value)); yield !between; } + case TRUE -> true; + case FALSE -> false; }; return criterionMatches; } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryBackendModuleTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryBackendModuleTest.java index fe06c6b1..d9f6df9c 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryBackendModuleTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryBackendModuleTest.java @@ -224,6 +224,9 @@ class MemoryBackendModuleTest extends BaseTest )); new InsertAction().execute(insertInput); + assertEquals(3, queryShapes(qInstance, table, session, new QFilterCriteria("id", QCriteriaOperator.TRUE)).size()); + assertEquals(0, queryShapes(qInstance, table, session, new QFilterCriteria("id", QCriteriaOperator.FALSE)).size()); + assertEquals(2, queryShapes(qInstance, table, session, new QFilterCriteria("id", QCriteriaOperator.IN, List.of(1, 2))).size()); assertEquals(1, queryShapes(qInstance, table, session, new QFilterCriteria("id", QCriteriaOperator.IN, List.of(3, 4))).size()); diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java index fb7d2156..3f5f58ab 100644 --- a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java +++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java @@ -626,6 +626,8 @@ public class AbstractMongoDBAction case IS_NOT_BLANK -> Filters.nor(filterIsBlank(fieldBackendName)); case BETWEEN -> filterBetween(fieldBackendName, values); case NOT_BETWEEN -> Filters.nor(filterBetween(fieldBackendName, values)); + case TRUE -> Filters.or(Filters.eq(fieldBackendName, "true"), Filters.ne(fieldBackendName, "true"), Filters.eq(fieldBackendName, null)); // todo test!! + case FALSE -> Filters.and(Filters.eq(fieldBackendName, "true"), Filters.ne(fieldBackendName, "true"), Filters.eq(fieldBackendName, null)); }); } diff --git a/qqq-backend-module-mongodb/src/test/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryActionTest.java b/qqq-backend-module-mongodb/src/test/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryActionTest.java index f9bc56db..3e51e7c2 100644 --- a/qqq-backend-module-mongodb/src/test/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryActionTest.java +++ b/qqq-backend-module-mongodb/src/test/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryActionTest.java @@ -213,6 +213,33 @@ class MongoDBQueryActionTest extends BaseTest } + /******************************************************************************* + ** + *******************************************************************************/ + @Test + public void testTrueQuery() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.TRUE))); + QueryOutput queryOutput = new MongoDBQueryAction().execute(queryInput); + assertEquals(5, queryOutput.getRecords().size(), "'TRUE' query should find all rows"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + public void testFalseQuery() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.FALSE))); + QueryOutput queryOutput = new MongoDBQueryAction().execute(queryInput); + assertEquals(0, queryOutput.getRecords().size(), "'FALSE' query should find no rows"); + } + + /******************************************************************************* ** 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 e3c60d0d..31d4092f 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 @@ -42,7 +42,6 @@ import java.util.stream.Collectors; import com.kingsrook.qqq.backend.core.actions.ActionHelper; import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter; import com.kingsrook.qqq.backend.core.context.QContext; -import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QValueException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; @@ -592,9 +591,17 @@ public abstract class AbstractRDBMSAction expectedNoOfParams = 2; break; } - default: + case TRUE: { - throw new IllegalArgumentException("Unexpected operator: " + criterion.getOperator()); + clause = " 1 = 1 "; + expectedNoOfParams = 0; + break; + } + case FALSE: + { + clause = " 0 = 1 "; + expectedNoOfParams = 0; + break; } } 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 004c7884..fa27d4ea 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 @@ -99,6 +99,34 @@ public class RDBMSQueryActionTest extends RDBMSActionTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + public void testTrueQuery() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.TRUE))); + QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); + assertEquals(5, queryOutput.getRecords().size(), "'TRUE' query should find all rows"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + public void testFalseQuery() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.setFilter(new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.FALSE))); + QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); + assertEquals(0, queryOutput.getRecords().size(), "'FALSE' query should find no rows"); + } + + + /******************************************************************************* ** *******************************************************************************/ From b1ba910ac7bed6181ee9cf42c71c9671b3458af3 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:01:33 -0500 Subject: [PATCH 25/58] CE-882 Update to use ReportDestination --- .../core/actions/reporting/ExportActionWithinRDBMSTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 8100c4c1..1b9671cc 100644 --- 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 @@ -28,6 +28,7 @@ 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.ReportDestination; 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; @@ -68,8 +69,9 @@ public class ExportActionWithinRDBMSTest extends RDBMSActionTest ExportInput exportInput = new ExportInput(); exportInput.setTableName(TestUtils.TABLE_NAME_ORDER); - exportInput.setReportFormat(ReportFormat.CSV); - exportInput.setReportOutputStream(baos); + exportInput.setReportDestination(new ReportDestination() + .withReportFormat(ReportFormat.CSV) + .withReportOutputStream(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); From fec96c39cb71b373b965921e2d1307b31811e014 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:02:51 -0500 Subject: [PATCH 26/58] CE-882 Add MultiRecordSecurityLocks --- .../core/instances/QInstanceValidator.java | 193 ++++++++++------- .../security/MultiRecordSecurityLock.java | 198 ++++++++++++++++++ 2 files changed, 310 insertions(+), 81 deletions(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java index 85c7e307..96ae3df3 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java @@ -86,6 +86,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData; import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportView; import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; 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.tables.AssociatedScript; @@ -711,7 +712,6 @@ public class QInstanceValidator { String prefix = "Table " + table.getName() + " "; - RECORD_SECURITY_LOCKS_LOOP: for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(table.getRecordSecurityLocks())) { if(!assertCondition(recordSecurityLock != null, prefix + "has a null recordSecurityLock (did you mean to give it a null list of locks?)")) @@ -719,95 +719,126 @@ public class QInstanceValidator continue; } - String securityKeyTypeName = recordSecurityLock.getSecurityKeyType(); - if(assertCondition(StringUtils.hasContent(securityKeyTypeName), prefix + "has a recordSecurityLock that is missing a securityKeyType")) + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) { - assertCondition(qInstance.getSecurityKeyType(securityKeyTypeName) != null, prefix + "has a recordSecurityLock with an unrecognized securityKeyType: " + securityKeyTypeName); + validateMultiRecordSecurityLock(qInstance, table, multiRecordSecurityLock, prefix); } - - prefix = "Table " + table.getName() + " recordSecurityLock (of key type " + securityKeyTypeName + ") "; - - assertCondition(recordSecurityLock.getLockScope() != null, prefix + " is missing its lockScope"); - - boolean hasAnyBadJoins = false; - for(String joinName : CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())) + else { - if(!assertCondition(qInstance.getJoin(joinName) != null, prefix + "has an unrecognized joinName: " + joinName)) - { - hasAnyBadJoins = true; - } + validateRecordSecurityLock(qInstance, table, recordSecurityLock, prefix); } - - String fieldName = recordSecurityLock.getFieldName(); - - //////////////////////////////////////////////////////////////////////////////// - // don't bother trying to validate field names if we know we have a bad join. // - //////////////////////////////////////////////////////////////////////////////// - if(assertCondition(StringUtils.hasContent(fieldName), prefix + "is missing a fieldName") && !hasAnyBadJoins) - { - if(fieldName.contains(".")) - { - if(assertCondition(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " looks like a join (has a dot), but no joinNameChain was given.")) - { - List joins = 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): // - // - securityFieldName = order.clientId // - // - joinNameChain = orderJoinOrderLineItem, orderLineItemJoinOrderLineItemExtrinsic // - // so - to navigate from the table to the security field, we need to reverse the joinNameChain, // - // and step (via tmpTable variable) back to the securityField // - /////////////////////////////////////////////////////////////////////////////////////////////////// - ArrayList joinNameChain = new ArrayList<>(CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())); - Collections.reverse(joinNameChain); - - QTableMetaData tmpTable = table; - - for(String joinName : joinNameChain) - { - QJoinMetaData join = qInstance.getJoin(joinName); - if(join == null) - { - errors.add(prefix + "joinNameChain contained an unrecognized join: " + joinName); - continue RECORD_SECURITY_LOCKS_LOOP; - } - - if(join.getLeftTable().equals(tmpTable.getName())) - { - joins.add(new QueryJoin(join)); - tmpTable = qInstance.getTable(join.getRightTable()); - } - else if(join.getRightTable().equals(tmpTable.getName())) - { - joins.add(new QueryJoin(join.flip())); - tmpTable = qInstance.getTable(join.getLeftTable()); - } - else - { - errors.add(prefix + "joinNameChain could not be followed through join: " + joinName); - continue RECORD_SECURITY_LOCKS_LOOP; - } - } - - assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); - } - } - else - { - if(assertCondition(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " does not look like a join (does not have a dot), but a joinNameChain was given.")) - { - assertNoException(() -> table.getField(fieldName), prefix + "has an unrecognized fieldName: " + fieldName); - } - } - } - - assertCondition(recordSecurityLock.getNullValueBehavior() != null, prefix + "is missing a nullValueBehavior"); } } + /******************************************************************************* + ** + *******************************************************************************/ + private void validateMultiRecordSecurityLock(QInstance qInstance, QTableMetaData table, MultiRecordSecurityLock multiRecordSecurityLock, String prefix) + { + assertCondition(multiRecordSecurityLock.getOperator() != null, prefix + "has a MultiRecordSecurityLock that is missing an operator"); + + for(RecordSecurityLock lock : multiRecordSecurityLock.getLocks()) + { + validateRecordSecurityLock(qInstance, table, lock, prefix); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void validateRecordSecurityLock(QInstance qInstance, QTableMetaData table, RecordSecurityLock recordSecurityLock, String prefix) + { + String securityKeyTypeName = recordSecurityLock.getSecurityKeyType(); + if(assertCondition(StringUtils.hasContent(securityKeyTypeName), prefix + "has a recordSecurityLock that is missing a securityKeyType")) + { + assertCondition(qInstance.getSecurityKeyType(securityKeyTypeName) != null, prefix + "has a recordSecurityLock with an unrecognized securityKeyType: " + securityKeyTypeName); + } + + prefix = "Table " + table.getName() + " recordSecurityLock (of key type " + securityKeyTypeName + ") "; + + assertCondition(recordSecurityLock.getLockScope() != null, prefix + " is missing its lockScope"); + + boolean hasAnyBadJoins = false; + for(String joinName : CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())) + { + if(!assertCondition(qInstance.getJoin(joinName) != null, prefix + "has an unrecognized joinName: " + joinName)) + { + hasAnyBadJoins = true; + } + } + + String fieldName = recordSecurityLock.getFieldName(); + + //////////////////////////////////////////////////////////////////////////////// + // don't bother trying to validate field names if we know we have a bad join. // + //////////////////////////////////////////////////////////////////////////////// + if(assertCondition(StringUtils.hasContent(fieldName), prefix + "is missing a fieldName") && !hasAnyBadJoins) + { + if(fieldName.contains(".")) + { + if(assertCondition(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " looks like a join (has a dot), but no joinNameChain was given.")) + { + List joins = 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): // + // - securityFieldName = order.clientId // + // - joinNameChain = orderJoinOrderLineItem, orderLineItemJoinOrderLineItemExtrinsic // + // so - to navigate from the table to the security field, we need to reverse the joinNameChain, // + // and step (via tmpTable variable) back to the securityField // + /////////////////////////////////////////////////////////////////////////////////////////////////// + ArrayList joinNameChain = new ArrayList<>(CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())); + Collections.reverse(joinNameChain); + + QTableMetaData tmpTable = table; + + for(String joinName : joinNameChain) + { + QJoinMetaData join = qInstance.getJoin(joinName); + if(join == null) + { + errors.add(prefix + "joinNameChain contained an unrecognized join: " + joinName); + return; + } + + if(join.getLeftTable().equals(tmpTable.getName())) + { + joins.add(new QueryJoin(join)); + tmpTable = qInstance.getTable(join.getRightTable()); + } + else if(join.getRightTable().equals(tmpTable.getName())) + { + joins.add(new QueryJoin(join.flip())); + tmpTable = qInstance.getTable(join.getLeftTable()); + } + else + { + errors.add(prefix + "joinNameChain could not be followed through join: " + joinName); + return; + } + } + + assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); + } + } + else + { + if(assertCondition(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " does not look like a join (does not have a dot), but a joinNameChain was given.")) + { + assertNoException(() -> table.getField(fieldName), prefix + "has an unrecognized fieldName: " + fieldName); + } + } + } + + assertCondition(recordSecurityLock.getNullValueBehavior() != null, prefix + "is missing a nullValueBehavior"); + } + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java new file mode 100644 index 00000000..04cb945b --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/MultiRecordSecurityLock.java @@ -0,0 +1,198 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.metadata.security; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Subclass of RecordSecurityLock, for combining multiple locks using a boolean + ** (AND/OR) condition. Note that the combined locks can themselves also be + ** Multi-locks, thus creating a tree of locks. + *******************************************************************************/ +public class MultiRecordSecurityLock extends RecordSecurityLock implements Cloneable +{ + private List locks = new ArrayList<>(); + private BooleanOperator operator; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected MultiRecordSecurityLock clone() throws CloneNotSupportedException + { + MultiRecordSecurityLock clone = (MultiRecordSecurityLock) super.clone(); + + ///////////////////////// + // deep-clone the list // + ///////////////////////// + if(locks != null) + { + clone.locks = new ArrayList<>(); + for(RecordSecurityLock lock : locks) + { + clone.locks.add(lock.clone()); + } + } + + return (clone); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public enum BooleanOperator + { + AND, + OR; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public QQueryFilter.BooleanOperator toFilterOperator() + { + return switch(this) + { + case AND -> QQueryFilter.BooleanOperator.AND; + case OR -> QQueryFilter.BooleanOperator.OR; + }; + } + } + + + + //////////////////////////////// + // todo - remove, this is POC // + //////////////////////////////// + static + { + new QTableMetaData() + .withName("savedReport") + .withRecordSecurityLock(new MultiRecordSecurityLock() + .withLocks(List.of( + new RecordSecurityLock() + .withFieldName("userId") + .withSecurityKeyType("user") + .withNullValueBehavior(NullValueBehavior.DENY) + .withLockScope(LockScope.READ_AND_WRITE), + new RecordSecurityLock() + .withFieldName("sharedReport.userId") + .withJoinNameChain(List.of("reportJoinSharedReport")) + .withSecurityKeyType("user") + .withNullValueBehavior(NullValueBehavior.DENY) + .withLockScope(LockScope.READ_AND_WRITE), // dynamic, from a value... + new RecordSecurityLock() + .withFieldName("sharedReport.groupId") + .withJoinNameChain(List.of("reportJoinSharedReport")) + .withSecurityKeyType("group") + .withNullValueBehavior(NullValueBehavior.DENY) + .withLockScope(LockScope.READ_AND_WRITE) // dynamic, from a value... + ))); + + } + + /******************************************************************************* + ** Getter for locks + *******************************************************************************/ + public List getLocks() + { + return (this.locks); + } + + + + /******************************************************************************* + ** Setter for locks + *******************************************************************************/ + public void setLocks(List locks) + { + this.locks = locks; + } + + + + /******************************************************************************* + ** Fluent setter for locks + *******************************************************************************/ + public MultiRecordSecurityLock withLocks(List locks) + { + this.locks = locks; + return (this); + } + + + + /******************************************************************************* + ** Fluently add one lock + *******************************************************************************/ + public MultiRecordSecurityLock withLock(RecordSecurityLock lock) + { + if(this.locks == null) + { + this.locks = new ArrayList<>(); + } + this.locks.add(lock); + return (this); + } + + + + /******************************************************************************* + ** Getter for operator + *******************************************************************************/ + public BooleanOperator getOperator() + { + return (this.operator); + } + + + + /******************************************************************************* + ** Setter for operator + *******************************************************************************/ + public void setOperator(BooleanOperator operator) + { + this.operator = operator; + } + + + + /******************************************************************************* + ** Fluent setter for operator + *******************************************************************************/ + public MultiRecordSecurityLock withOperator(BooleanOperator operator) + { + this.operator = operator; + return (this); + } + +} From 1e1b660979e08ae5db7306e47eb9a4d27fc0da47 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:03:03 -0500 Subject: [PATCH 27/58] CE-882 Better(?) toString --- .../actions/tables/query/QQueryFilter.java | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) 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 b6c58ed4..0ed4544d 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 @@ -361,23 +361,44 @@ public class QQueryFilter implements Serializable, Cloneable StringBuilder rs = new StringBuilder("("); try { + int criteriaIndex = 0; for(QFilterCriteria criterion : CollectionUtils.nonNullList(criteria)) { - rs.append(criterion).append(" ").append(getBooleanOperator()).append(" "); + if(criteriaIndex > 0) + { + rs.append(" ").append(getBooleanOperator()).append(" "); + } + rs.append(criterion); + criteriaIndex++; } - for(QQueryFilter subFilter : CollectionUtils.nonNullList(subFilters)) + if(CollectionUtils.nullSafeHasContents(subFilters)) { - rs.append(subFilter); + rs.append("Sub:{"); + int subIndex = 0; + for(QQueryFilter subFilter : CollectionUtils.nonNullList(subFilters)) + { + if(subIndex > 0) + { + rs.append(" ").append(getBooleanOperator()).append(" "); + } + rs.append(subFilter); + subIndex++; + } + rs.append("}"); } + rs.append(")"); - rs.append("OrderBy["); - for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys)) + if(CollectionUtils.nullSafeHasContents(orderBys)) { - rs.append(orderBy).append(","); + rs.append("OrderBy["); + for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys)) + { + rs.append(orderBy).append(","); + } + rs.append("]"); } - rs.append("]"); } catch(Exception e) { From 5fe95ab0beb807206ddd6532db187a06bacfd8c1 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:03:34 -0500 Subject: [PATCH 28/58] CE-882 Add filterForReadLockTree (required cloning in RecordSecurityLock) --- .../metadata/security/RecordSecurityLock.java | 41 ++++- .../security/RecordSecurityLockFilters.java | 35 ++++ .../RecordSecurityLockFiltersTest.java | 160 ++++++++++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFiltersTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java index b16deac0..37d34cd1 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java @@ -22,6 +22,7 @@ package com.kingsrook.qqq.backend.core.model.metadata.security; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -41,7 +42,7 @@ import java.util.Map; ** - READ_AND_WRITE means that users cannot read or write records without a valid key. ** - WRITE means that users cannot write records without a valid key (but they can read them). *******************************************************************************/ -public class RecordSecurityLock +public class RecordSecurityLock implements Cloneable { private String securityKeyType; private String fieldName; @@ -50,6 +51,26 @@ public class RecordSecurityLock private LockScope lockScope = LockScope.READ_AND_WRITE; + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected RecordSecurityLock clone() throws CloneNotSupportedException + { + RecordSecurityLock clone = (RecordSecurityLock) super.clone(); + + ///////////////////////// + // deep-clone the list // + ///////////////////////// + if(joinNameChain != null) + { + clone.joinNameChain = new ArrayList<>(); + clone.joinNameChain.addAll(joinNameChain); + } + + return (clone); + } + /******************************************************************************* @@ -265,4 +286,22 @@ public class RecordSecurityLock return (this); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String toString() + { + return "RecordSecurityLock{" + + "securityKeyType='" + securityKeyType + '\'' + + ", fieldName='" + fieldName + '\'' + + ", joinNameChain=" + joinNameChain + + ", nullValueBehavior=" + nullValueBehavior + + ", lockScope=" + lockScope + + '}'; + } + } + diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java index c8c7e9dc..e5749087 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java @@ -46,6 +46,41 @@ public class RecordSecurityLockFilters + /******************************************************************************* + ** filter a list of locks so that we only see the ones that apply to reads. + *******************************************************************************/ + public static MultiRecordSecurityLock filterForReadLockTree(List recordSecurityLocks) + { + if(recordSecurityLocks == null) + { + return (null); + } + + MultiRecordSecurityLock result = new MultiRecordSecurityLock(); + result.setOperator(MultiRecordSecurityLock.BooleanOperator.AND); + + for(RecordSecurityLock recordSecurityLock : recordSecurityLocks) + { + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) + { + MultiRecordSecurityLock filteredSubLock = filterForReadLockTree(multiRecordSecurityLock.getLocks()); + filteredSubLock.setOperator(multiRecordSecurityLock.getOperator()); + result.withLock(filteredSubLock); + } + else + { + if(RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) + { + result.withLock(recordSecurityLock); + } + } + } + + return (result); + } + + + /******************************************************************************* ** filter a list of locks so that we only see the ones that apply to writes. *******************************************************************************/ diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFiltersTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFiltersTest.java new file mode 100644 index 00000000..b5c360b1 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFiltersTest.java @@ -0,0 +1,160 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.metadata.security; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock.BooleanOperator.AND; +import static com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock.BooleanOperator.OR; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for RecordSecurityLockFilters + *******************************************************************************/ +class RecordSecurityLockFiltersTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() + { + MultiRecordSecurityLock nullBecauseNull = RecordSecurityLockFilters.filterForReadLockTree(null); + assertNull(nullBecauseNull); + + MultiRecordSecurityLock emptyBecauseEmptyList = RecordSecurityLockFilters.filterForReadLockTree(List.of()); + assertEquals(0, emptyBecauseEmptyList.getLocks().size()); + + MultiRecordSecurityLock emptyBecauseAllWrite = RecordSecurityLockFilters.filterForReadLockTree(List.of( + new RecordSecurityLock().withFieldName("A").withLockScope(RecordSecurityLock.LockScope.WRITE), + new RecordSecurityLock().withFieldName("B").withLockScope(RecordSecurityLock.LockScope.WRITE) + )); + assertEquals(0, emptyBecauseAllWrite.getLocks().size()); + + MultiRecordSecurityLock onlyA = RecordSecurityLockFilters.filterForReadLockTree(List.of( + new RecordSecurityLock().withFieldName("A").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("B").withLockScope(RecordSecurityLock.LockScope.WRITE) + )); + assertMultiRecordSecurityLock(onlyA, AND, "A"); + + MultiRecordSecurityLock twoOutOfThreeTopLevel = RecordSecurityLockFilters.filterForReadLockTree(List.of( + new RecordSecurityLock().withFieldName("A").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("B").withLockScope(RecordSecurityLock.LockScope.WRITE), + new RecordSecurityLock().withFieldName("C").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE) + )); + assertMultiRecordSecurityLock(twoOutOfThreeTopLevel, AND, "A", "C"); + + MultiRecordSecurityLock treeOfAllReads = RecordSecurityLockFilters.filterForReadLockTree(List.of( + new MultiRecordSecurityLock().withOperator(OR).withLocks(List.of( + new RecordSecurityLock().withFieldName("A").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("B").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE) + )), + new MultiRecordSecurityLock().withOperator(OR).withLocks(List.of( + new RecordSecurityLock().withFieldName("C").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("D").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE) + )) + )); + assertEquals(2, treeOfAllReads.getLocks().size()); + assertEquals(AND, treeOfAllReads.getOperator()); + assertMultiRecordSecurityLock((MultiRecordSecurityLock) treeOfAllReads.getLocks().get(0), OR, "A", "B"); + assertMultiRecordSecurityLock((MultiRecordSecurityLock) treeOfAllReads.getLocks().get(1), OR, "C", "D"); + + MultiRecordSecurityLock treeWithOneBranchReadsOneBranchWrites = RecordSecurityLockFilters.filterForReadLockTree(List.of( + new MultiRecordSecurityLock().withOperator(OR).withLocks(List.of( + new RecordSecurityLock().withFieldName("A").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("B").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE) + )), + new MultiRecordSecurityLock().withOperator(OR).withLocks(List.of( + new RecordSecurityLock().withFieldName("C").withLockScope(RecordSecurityLock.LockScope.WRITE), + new RecordSecurityLock().withFieldName("D").withLockScope(RecordSecurityLock.LockScope.WRITE) + )) + )); + assertEquals(2, treeWithOneBranchReadsOneBranchWrites.getLocks().size()); + assertEquals(AND, treeWithOneBranchReadsOneBranchWrites.getOperator()); + assertMultiRecordSecurityLock((MultiRecordSecurityLock) treeWithOneBranchReadsOneBranchWrites.getLocks().get(0), OR, "A", "B"); + assertMultiRecordSecurityLock((MultiRecordSecurityLock) treeWithOneBranchReadsOneBranchWrites.getLocks().get(1), OR); + + MultiRecordSecurityLock deepSparseTree = RecordSecurityLockFilters.filterForReadLockTree(List.of( + new MultiRecordSecurityLock().withOperator(OR).withLocks(List.of( + new MultiRecordSecurityLock().withOperator(AND).withLocks(List.of( + new RecordSecurityLock().withFieldName("A").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("B").withLockScope(RecordSecurityLock.LockScope.WRITE) + )), + new RecordSecurityLock().withFieldName("C").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("D").withLockScope(RecordSecurityLock.LockScope.WRITE) + )), + new MultiRecordSecurityLock().withOperator(OR).withLocks(List.of( + new MultiRecordSecurityLock().withOperator(AND).withLocks(List.of( + new RecordSecurityLock().withFieldName("E").withLockScope(RecordSecurityLock.LockScope.WRITE), + new RecordSecurityLock().withFieldName("F").withLockScope(RecordSecurityLock.LockScope.WRITE) + )), + new MultiRecordSecurityLock().withOperator(AND).withLocks(List.of( + new RecordSecurityLock().withFieldName("G").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("H").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE) + )) + )), + new RecordSecurityLock().withFieldName("I").withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE), + new RecordSecurityLock().withFieldName("J").withLockScope(RecordSecurityLock.LockScope.WRITE) + )); + + assertEquals(3, deepSparseTree.getLocks().size()); + assertEquals(AND, deepSparseTree.getOperator()); + MultiRecordSecurityLock deepChild0 = (MultiRecordSecurityLock) deepSparseTree.getLocks().get(0); + assertEquals(2, deepChild0.getLocks().size()); + assertEquals(OR, deepChild0.getOperator()); + MultiRecordSecurityLock deepGrandChild0 = (MultiRecordSecurityLock) deepChild0.getLocks().get(0); + assertMultiRecordSecurityLock(deepGrandChild0, AND, "A"); + assertEquals("C", deepChild0.getLocks().get(1).getFieldName()); + + MultiRecordSecurityLock deepChild1 = (MultiRecordSecurityLock) deepSparseTree.getLocks().get(1); + assertEquals(2, deepChild1.getLocks().size()); + assertEquals(OR, deepChild1.getOperator()); + MultiRecordSecurityLock deepGrandChild1 = (MultiRecordSecurityLock) deepChild1.getLocks().get(0); + assertMultiRecordSecurityLock(deepGrandChild1, AND); + MultiRecordSecurityLock deepGrandChild2 = (MultiRecordSecurityLock) deepChild1.getLocks().get(1); + assertMultiRecordSecurityLock(deepGrandChild2, AND, "G", "H"); + + assertEquals("I", deepSparseTree.getLocks().get(2).getFieldName()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void assertMultiRecordSecurityLock(MultiRecordSecurityLock lock, MultiRecordSecurityLock.BooleanOperator operator, String... lockFieldNames) + { + assertEquals(lockFieldNames.length, lock.getLocks().size()); + assertEquals(operator, lock.getOperator()); + + for(int i = 0; i < lockFieldNames.length; i++) + { + assertEquals(lockFieldNames[i], lock.getLocks().get(i).getFieldName()); + } + } + +} \ No newline at end of file From bc47f8b80c08404fd507c83d43aa4174f59987d9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:06:43 -0500 Subject: [PATCH 29/58] CE-882 Handle processing MultiRecorSecurityLocks (which means building trees of securityFilters, with AND/OR logic) --- .../actions/tables/query/JoinsContext.java | 190 ++++++++++++++---- 1 file changed, 148 insertions(+), 42 deletions(-) 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 986cab7e..8252e84f 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 @@ -40,6 +40,7 @@ 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.MultiRecordSecurityLock; 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; @@ -67,6 +68,11 @@ public class JoinsContext private final QQueryFilter securityFilter; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // pointer either at securityFilter, or at a sub-filter within it, for when we're doing a recursive build-out of multi-locks // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + private QQueryFilter securityFilterCursor; + //////////////////////////////////////////////////////////////// // note - will have entries for all tables, not just aliases. // //////////////////////////////////////////////////////////////// @@ -76,6 +82,7 @@ public class JoinsContext // we will get a TON of more output if this gets turned up, so be cautious // ///////////////////////////////////////////////////////////////////////////// private Level logLevel = Level.OFF; + private Level logLevelForFilter = Level.OFF; @@ -89,6 +96,7 @@ public class JoinsContext this.mainTableName = tableName; this.queryJoins = new MutableList<>(queryJoins); this.securityFilter = new QQueryFilter(); + this.securityFilterCursor = this.securityFilter; // log("--- START ----------------------------------------------------------------------", logPair("mainTable", tableName)); dumpDebug(true, false); @@ -102,13 +110,16 @@ public class JoinsContext // make sure that all tables specified in filter columns are being brought into the query as joins // ///////////////////////////////////////////////////////////////////////////////////////////////////// ensureFilterIsRepresented(filter); + logFilter("After ensureFilterIsRepresented:", securityFilter); /////////////////////////////////////////////////////////////////////////////////////// // 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()))) + MultiRecordSecurityLock multiRecordSecurityLock = RecordSecurityLockFilters.filterForReadLockTree(CollectionUtils.nonNullList(instance.getTable(tableName).getRecordSecurityLocks())); + for(RecordSecurityLock lock : multiRecordSecurityLock.getLocks()) { - ensureRecordSecurityLockIsRepresented(tableName, tableName, recordSecurityLock, null); + ensureRecordSecurityLockIsRepresented(tableName, tableName, lock, null); + logFilter("After ensureRecordSecurityLockIsRepresented[fieldName=" + lock.getFieldName() + "]:", securityFilter); } /////////////////////////////////////////////////////////////////////////////////// @@ -117,11 +128,13 @@ public class JoinsContext // or a join implicitly added from a filter may also not have its join meta data // /////////////////////////////////////////////////////////////////////////////////// fillInMissingJoinMetaData(); + logFilter("After fillInMissingJoinMetaData:", securityFilter); /////////////////////////////////////////////////////////////// // ensure any joins that contribute a recordLock are present // /////////////////////////////////////////////////////////////// ensureAllJoinRecordSecurityLocksAreRepresented(instance); + logFilter("After ensureAllJoinRecordSecurityLocksAreRepresented:", securityFilter); //////////////////////////////////////////////////////////////////////////////////// // if there were any security filters built, then put those into the input filter // @@ -168,10 +181,7 @@ public class JoinsContext filter.addSubFilter(replacementFilter); } - for(QQueryFilter subFilter : securityFilter.getSubFilters()) - { - filter.addSubFilter(subFilter); - } + filter.addSubFilter(securityFilter); } @@ -196,10 +206,11 @@ public class JoinsContext { boolean addedAnyForThisJoin = false; - ///////////////////////////////////////////////// - // avoid double-processing the same query join // - ///////////////////////////////////////////////// - if(processedQueryJoins.contains(queryJoin)) + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // avoid double-processing the same query join // + // or adding security filters for a join who was only added to the query so that we could add locks (an ImplicitQueryJoinForSecurityLock) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(processedQueryJoins.contains(queryJoin) || queryJoin instanceof ImplicitQueryJoinForSecurityLock) { continue; } @@ -209,9 +220,11 @@ public class JoinsContext // 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 : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks()))) + + MultiRecordSecurityLock multiRecordSecurityLock = RecordSecurityLockFilters.filterForReadLockTree(CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks())); + for(RecordSecurityLock lock : multiRecordSecurityLock.getLocks()) { - List addedQueryJoins = ensureRecordSecurityLockIsRepresented(joinTable.getName(), queryJoin.getJoinTableOrItsAlias(), recordSecurityLock, queryJoin); + List addedQueryJoins = ensureRecordSecurityLockIsRepresented(joinTable.getName(), queryJoin.getJoinTableOrItsAlias(), lock, queryJoin); ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if any joins were added by this call, add them to the set of processed ones, so they don't get re-processed. // @@ -258,6 +271,43 @@ public class JoinsContext { List addedQueryJoins = new ArrayList<>(); + //////////////////////////////////////////////////////////////////////////// + // if this lock is a multi-lock, then recursively process its child-locks // + //////////////////////////////////////////////////////////////////////////// + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) + { + log("Processing MultiRecordSecurityLock..."); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // make a new level in the filter-tree - storing old cursor, and updating cursor to point at new level // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + QQueryFilter oldSecurityFilterCursor = this.securityFilterCursor; + QQueryFilter nextLevelSecurityFilter = new QQueryFilter(); + this.securityFilterCursor.addSubFilter(nextLevelSecurityFilter); + this.securityFilterCursor = nextLevelSecurityFilter; + + /////////////////////////////////////// + // set the boolean operator to match // + /////////////////////////////////////// + nextLevelSecurityFilter.setBooleanOperator(multiRecordSecurityLock.getOperator().toFilterOperator()); + + ////////////////////// + // process children // + ////////////////////// + for(RecordSecurityLock childLock : CollectionUtils.nonNullList(multiRecordSecurityLock.getLocks())) + { + log(" - Recursive call for childLock: " + childLock); + addedQueryJoins.addAll(ensureRecordSecurityLockIsRepresented(tableName, tableNameOrAlias, childLock, sourceQueryJoin)); + } + + //////////////////// + // restore cursor // + //////////////////// + this.securityFilterCursor = oldSecurityFilterCursor; + + return addedQueryJoins; + } + /////////////////////////////////////////////////////////////////////////////////////////////////// // 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): // @@ -347,6 +397,12 @@ public class JoinsContext .withBaseTableOrAlias(baseTableOrAlias) .withAlias(securityFieldTableAlias); + if(securityFilterCursor.getBooleanOperator() == QQueryFilter.BooleanOperator.OR) + { + queryJoin.withType(QueryJoin.Type.LEFT); + chainIsInner = false; + } + addQueryJoin(queryJoin, "forRecordSecurityLock (non-flipped)", "- "); addedQueryJoins.add(queryJoin); tmpTable = instance.getTable(join.getRightTable()); @@ -360,6 +416,12 @@ public class JoinsContext .withBaseTableOrAlias(baseTableOrAlias) .withAlias(securityFieldTableAlias); + if(securityFilterCursor.getBooleanOperator() == QQueryFilter.BooleanOperator.OR) + { + queryJoin.withType(QueryJoin.Type.LEFT); + chainIsInner = false; + } + addQueryJoin(queryJoin, "forRecordSecurityLock (flipped)", "- "); addedQueryJoins.add(queryJoin); tmpTable = instance.getTable(join.getLeftTable()); @@ -404,13 +466,16 @@ public class JoinsContext // 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()); + boolean haveAllAccessKey = false; if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName())) { - /////////////////////////////////////////////////////////////////////////////// - // if we have all-access on this key, then we don't need a criterion for it. // - /////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if we have all-access on this key, then we don't need a criterion for it (as long as we're in an AND filter) // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(session.hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) { + haveAllAccessKey = true; + if(sourceQueryJoin != null) { //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -420,7 +485,14 @@ public class JoinsContext sourceQueryJoin.withSecurityCriteria(new ArrayList<>()); } - return; + //////////////////////////////////////////////////////////////////////////////////////// + // if we're in an AND filter, then we don't need a criteria for this lock, so return. // + //////////////////////////////////////////////////////////////////////////////////////// + boolean inAnAndFilter = securityFilterCursor.getBooleanOperator() == QQueryFilter.BooleanOperator.AND; + if(inAnAndFilter) + { + return; + } } } @@ -462,45 +534,58 @@ public class JoinsContext LOG.debug("Error getting field type... Trying Integer", e); } - List securityKeyValues = session.getSecurityKeyValues(recordSecurityLock.getSecurityKeyType(), type); - if(CollectionUtils.nullSafeIsEmpty(securityKeyValues)) + if(haveAllAccessKey) { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // 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())); - } + //////////////////////////////////////////////////////////////////////////////////////////// + // if we have an all access key (but we got here because we're part of an OR query), then // + // write a criterion that will always be true - e.g., field=field // + //////////////////////////////////////////////////////////////////////////////////////////// + lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.TRUE)); } 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())) + List securityKeyValues = session.getSecurityKeyValues(recordSecurityLock.getSecurityKeyType(), type); + if(CollectionUtils.nullSafeIsEmpty(securityKeyValues)) { - lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_NULL_OR_IN, 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 - maybe avoid running the whole query - as you're not allowed ANY records (based on boolean tree down to this point) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.FALSE)); + } } else { - lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IN, securityKeyValues)); + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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 // + // ... unless we're writing an OR filter. then we need the condition in the WHERE clause // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(sourceQueryJoin != null) + boolean doNotPutCriteriaInJoinOn = securityFilterCursor.getBooleanOperator() == QQueryFilter.BooleanOperator.OR; + if(sourceQueryJoin != null && !doNotPutCriteriaInJoinOn) { sourceQueryJoin.withSecurityCriteria(lockCriteria); } @@ -518,6 +603,11 @@ public class JoinsContext /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(isOuter) { + if(table == null) + { + table = QContext.getQInstance().getTable(aliasToTableNameMap.get(tableNameOrAlias)); + } + lockFilter.setBooleanOperator(QQueryFilter.BooleanOperator.OR); lockFilter.addCriteria(new QFilterCriteria(tableNameOrAlias + "." + table.getPrimaryKeyField(), QCriteriaOperator.IS_BLANK)); } @@ -526,7 +616,7 @@ public class JoinsContext ///////////////////////////////////////////////////////////////////////////////////////////////////// // If this filter isn't for a queryJoin, then just add it to the main list of security sub-filters // ///////////////////////////////////////////////////////////////////////////////////////////////////// - this.securityFilter.addSubFilter(lockFilter); + this.securityFilterCursor.addSubFilter(lockFilter); } } @@ -1068,6 +1158,19 @@ public class JoinsContext } + /******************************************************************************* + ** + *******************************************************************************/ + private void logFilter(String message, QQueryFilter filter) + { + if(logLevelForFilter.equals(Level.OFF)) + { + return; + } + System.out.println(message + "\n" + filter); + } + + /******************************************************************************* ** Print (to stdout, for easier reading) the object in a big table format for @@ -1117,6 +1220,9 @@ public class JoinsContext } System.out.print(rs); + + System.out.println(securityFilter); + if(isEnd) { System.out.println(StringUtils.safeTruncate("--- End " + "-".repeat(full), full) + "\n"); From 21657b918cf3f20b620cb24129ea61c8ca04d69d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:08:02 -0500 Subject: [PATCH 30/58] CE-882 Updated test - passes real-world scenarios we need for story --- .../sharing/SharingMetaDataProvider.java | 78 ++++-- .../module/rdbms/sharing/SharingTest.java | 196 +++++++++++---- .../rdbms/sharing/model/AssetAudienceInt.java | 226 ------------------ .../model/{Audience.java => SharedAsset.java} | 128 +++++----- .../prime-test-database-sharing-test.sql | 63 ++--- 5 files changed, 306 insertions(+), 385 deletions(-) delete mode 100644 qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java rename qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/{Audience.java => SharedAsset.java} (80%) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java index a961fc58..51b1890c 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java @@ -22,20 +22,24 @@ package com.kingsrook.qqq.backend.module.rdbms.sharing; +import java.util.List; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +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.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; 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.tables.QTableMetaData; import com.kingsrook.qqq.backend.module.rdbms.TestUtils; import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Asset; -import com.kingsrook.qqq.backend.module.rdbms.sharing.model.AssetAudienceInt; -import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Audience; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Client; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Group; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.SharedAsset; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.User; @@ -47,6 +51,11 @@ public class SharingMetaDataProvider public static final String USER_ID_KEY_TYPE = "userIdKey"; public static final String USER_ID_ALL_ACCESS_KEY_TYPE = "userIdAllAccessKey"; + public static final String GROUP_ID_KEY_TYPE = "groupIdKey"; + public static final String GROUP_ID_ALL_ACCESS_KEY_TYPE = "groupIdAllAccessKey"; + + private static final String ASSET_JOIN_SHARED_ASSET = "assetJoinSharedAsset"; + /******************************************************************************* @@ -58,30 +67,58 @@ public class SharingMetaDataProvider .withName(USER_ID_KEY_TYPE) .withAllAccessKeyName(USER_ID_ALL_ACCESS_KEY_TYPE)); + qInstance.addSecurityKeyType(new QSecurityKeyType() + .withName(GROUP_ID_KEY_TYPE) + .withAllAccessKeyName(GROUP_ID_ALL_ACCESS_KEY_TYPE)); + qInstance.addTable(new QTableMetaData() .withName(Asset.TABLE_NAME) .withPrimaryKeyField("id") .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withBackendDetails(new RDBMSTableBackendDetails().withTableName("asset")) .withFieldsFromEntity(Asset.class) - .withRecordSecurityLock(new RecordSecurityLock() - .withSecurityKeyType(USER_ID_KEY_TYPE) - .withFieldName("userId"))); + + //////////////////////////////////////// + // This is original - just owner/user // + //////////////////////////////////////// + // .withRecordSecurityLock(new RecordSecurityLock() + // .withSecurityKeyType(USER_ID_KEY_TYPE) + // .withFieldName("userId"))); + + .withRecordSecurityLock(new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(USER_ID_KEY_TYPE) + .withFieldName("userId")) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(USER_ID_KEY_TYPE) + .withFieldName("sharedAsset.userId") + .withJoinNameChain(List.of(ASSET_JOIN_SHARED_ASSET))) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(GROUP_ID_KEY_TYPE) + .withFieldName("sharedAsset.groupId") + .withJoinNameChain(List.of(ASSET_JOIN_SHARED_ASSET))) + )); QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Asset.TABLE_NAME)); qInstance.addTable(new QTableMetaData() - .withName(Audience.TABLE_NAME) + .withName(SharedAsset.TABLE_NAME) + .withBackendDetails(new RDBMSTableBackendDetails().withTableName("shared_asset")) .withPrimaryKeyField("id") .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) - .withFieldsFromEntity(Audience.class)); - QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Audience.TABLE_NAME)); - - qInstance.addTable(new QTableMetaData() - .withName(AssetAudienceInt.TABLE_NAME) - .withBackendDetails(new RDBMSTableBackendDetails().withTableName("asset_audience_int")) - .withPrimaryKeyField("id") - .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) - .withFieldsFromEntity(AssetAudienceInt.class)); - QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(AssetAudienceInt.TABLE_NAME)); + .withFieldsFromEntity(SharedAsset.class) + .withRecordSecurityLock(new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(USER_ID_KEY_TYPE) + .withHint(RecordSecurityLock.QueryHint.DO_NOT_PUT_CRITERIA_IN_JOIN_ON) + .withFieldName("userId")) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(GROUP_ID_KEY_TYPE) + .withHint(RecordSecurityLock.QueryHint.DO_NOT_PUT_CRITERIA_IN_JOIN_ON) + .withFieldName("groupId")) + )); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(SharedAsset.TABLE_NAME)); qInstance.addTable(new QTableMetaData() .withName(User.TABLE_NAME) @@ -111,7 +148,14 @@ public class SharingMetaDataProvider qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Group.TABLE_NAME)); qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Client.TABLE_NAME)); qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Asset.TABLE_NAME)); - qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(Audience.TABLE_NAME)); + + qInstance.addJoin(new QJoinMetaData() + .withName(ASSET_JOIN_SHARED_ASSET) + .withLeftTable(Asset.TABLE_NAME) + .withRightTable(SharedAsset.TABLE_NAME) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", "assetId")) + ); } } diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java index 38154d51..3a27f1b3 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java @@ -22,7 +22,7 @@ package com.kingsrook.qqq.backend.module.rdbms.sharing; -import java.util.ArrayList; +import java.sql.SQLException; import java.util.HashMap; import java.util.List; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; @@ -31,17 +31,21 @@ 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.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.metadata.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.module.rdbms.TestUtils; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Asset; -import com.kingsrook.qqq.backend.module.rdbms.sharing.model.AssetAudienceInt; -import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Audience; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Group; +import com.kingsrook.qqq.backend.module.rdbms.sharing.model.SharedAsset; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.GROUP_ID_KEY_TYPE; +import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.USER_ID_ALL_ACCESS_KEY_TYPE; +import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.USER_ID_KEY_TYPE; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -50,6 +54,24 @@ import static org.junit.jupiter.api.Assertions.assertEquals; *******************************************************************************/ public class SharingTest { + ////////////// + // user ids // + ////////////// + public static final int HOMER_ID = 1; + public static final int MARGE_ID = 2; + public static final int BART_ID = 3; + public static final int LISA_ID = 4; + public static final int BURNS_ID = 5; + + /////////////// + // group ids // + /////////////// + public static final int SIMPSONS_ID = 1; + public static final int POWER_PLANT_ID = 2; + public static final int BOGUS_GROUP_ID = Integer.MAX_VALUE; + + + /******************************************************************************* ** *******************************************************************************/ @@ -76,48 +98,35 @@ public class SharingTest QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_ALL_ACCESS_KEY_TYPE, true); List userList = List.of( - new User().withId(100).withUsername("homer"), - new User().withId(101).withUsername("marge"), - new User().withId(102).withUsername("bart"), - new User().withId(103).withUsername("lisa"), - new User().withId(110).withUsername("burns")); + new User().withId(HOMER_ID).withUsername("homer"), + new User().withId(MARGE_ID).withUsername("marge"), + new User().withId(BART_ID).withUsername("bart"), + new User().withId(LISA_ID).withUsername("lisa"), + new User().withId(BURNS_ID).withUsername("burns")); new InsertAction().execute(new InsertInput(User.TABLE_NAME).withRecordEntities(userList)); List groupList = List.of( - new Group().withId(200).withName("simpsons"), - new Group().withId(201).withName("powerplant")); + new Group().withId(SIMPSONS_ID).withName("simpsons"), + new Group().withId(POWER_PLANT_ID).withName("powerplant")); new InsertAction().execute(new InsertInput(Group.TABLE_NAME).withRecordEntities(groupList)); List assetList = List.of( - new Asset().withId(3000).withName("742evergreen").withUserId(100), - new Asset().withId(3001).withName("beer").withUserId(100), - new Asset().withId(3010).withName("bed").withUserId(101), - new Asset().withId(3020).withName("skateboard").withUserId(102), - new Asset().withId(3030).withName("saxamaphone").withUserId(103)); + new Asset().withId(1).withName("742evergreen").withUserId(HOMER_ID), + new Asset().withId(2).withName("beer").withUserId(HOMER_ID), + new Asset().withId(3).withName("car").withUserId(MARGE_ID), + new Asset().withId(4).withName("skateboard").withUserId(BART_ID), + new Asset().withId(5).withName("santaslittlehelper").withUserId(BART_ID), + new Asset().withId(6).withName("saxamaphone").withUserId(LISA_ID), + new Asset().withId(7).withName("radiation").withUserId(BURNS_ID)); new InsertAction().execute(new InsertInput(Asset.TABLE_NAME).withRecordEntities(assetList)); - List assetAudienceIntList = List.of( - // homer shares his house with the simpson family (group) - new AssetAudienceInt().withAssetId(3000).withAudienceId(200), - - // marge shares a bed with homer - new AssetAudienceInt().withAssetId(3010).withAudienceId(100) - + List sharedAssetList = List.of( + new SharedAsset().withAssetId(1).withGroupId(SIMPSONS_ID), // homer shares his house with the simpson family (group) + new SharedAsset().withAssetId(3).withUserId(HOMER_ID), // marge shares a car with homer + new SharedAsset().withAssetId(5).withGroupId(SIMPSONS_ID), // bart shares santa's little helper with the whole family + new SharedAsset().withAssetId(7).withGroupId(POWER_PLANT_ID) // burns shares radiation with the power plant ); - new InsertAction().execute(new InsertInput(AssetAudienceInt.TABLE_NAME).withRecordEntities(assetAudienceIntList)); - - List audienceList = new ArrayList<>(); - for(QRecordEntity entity : userList) - { - User user = (User) entity; - audienceList.add(new Audience().withId(user.getId()).withName(user.getUsername()).withType("user")); - } - for(QRecordEntity entity : groupList) - { - Group group = (Group) entity; - audienceList.add(new Audience().withId(group.getId()).withName(group.getName()).withType("group")); - } - new InsertAction().execute(new InsertInput(Audience.TABLE_NAME).withRecordEntities(audienceList)); + new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntities(sharedAssetList)); QContext.getQSession().withSecurityKeyValues(new HashMap<>()); } @@ -128,22 +137,127 @@ public class SharingTest ** *******************************************************************************/ @Test - void test() throws QException + void testAssetWithUserIdOnlySecurityKey() throws QException { + //////////////////////////////////////////////////////////////////// + // update the asset table to change its lock to only be on userId // + //////////////////////////////////////////////////////////////////// + QContext.getQInstance().getTable(Asset.TABLE_NAME) + .withRecordSecurityLocks(List.of(new RecordSecurityLock() + .withSecurityKeyType(USER_ID_KEY_TYPE) + .withFieldName("userId"))); + + //////////////////////////////////////////////////////// + // with nothing in session, make sure we find nothing // + //////////////////////////////////////////////////////// assertEquals(0, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + //////////////////////////////////// + // marge direct owner only of car // + //////////////////////////////////// QContext.getQSession().withSecurityKeyValues(new HashMap<>()); - QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 101); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, MARGE_ID); assertEquals(1, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + ///////////////////////////////////////////////// + // homer direct owner of 742evergreen and beer // + ///////////////////////////////////////////////// QContext.getQSession().withSecurityKeyValues(new HashMap<>()); - QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 100); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, HOMER_ID); assertEquals(2, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + ///////////////////////////////////////////////////// + // marge & homer - own car, 742evergreen, and beer // + ///////////////////////////////////////////////////// QContext.getQSession().withSecurityKeyValues(new HashMap<>()); - QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 100); - QContext.getQSession().withSecurityKeyValue(SharingMetaDataProvider.USER_ID_KEY_TYPE, 101); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, HOMER_ID); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, MARGE_ID); assertEquals(3, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); } + + + /******************************************************************************* + ** normally (?) maybe we wouldn't query sharedAsset directly (we'd instead query + ** for asset, and understand that there's a security lock coming from sharedAsset), + ** but this test is here as we build up making a more complex lock like that. + *******************************************************************************/ + @Test + void testSharedAssetDirectly() throws QException + { + //////////////////////////////////////////////////////// + // with nothing in session, make sure we find nothing // + //////////////////////////////////////////////////////// + assertEquals(0, new QueryAction().execute(new QueryInput(SharedAsset.TABLE_NAME)).getRecords().size()); + + ///////////////////////////////////// + // homer has a car shared with him // + ///////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, HOMER_ID); + assertEquals(1, new QueryAction().execute(new QueryInput(SharedAsset.TABLE_NAME)).getRecords().size()); + + ///////////////////////////////////////////////////////////////////////////////////////// + // now put homer's groups in the session as well - and we should find 742evergreen too // + ///////////////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, HOMER_ID); + QContext.getQSession().withSecurityKeyValue(GROUP_ID_KEY_TYPE, SIMPSONS_ID); + QContext.getQSession().withSecurityKeyValue(GROUP_ID_KEY_TYPE, POWER_PLANT_ID); + List records = new QueryAction().execute(new QueryInput(SharedAsset.TABLE_NAME)).getRecords(); + assertEquals(4, records.size()); + } + + + /******************************************************************************* + ** real-world use-case (e.g., why sharing concept exists) - query the asset table + ** + *******************************************************************************/ + @Test + void testAssetsWithLockThroughSharing() throws QException, SQLException + { + //////////////////////////////////////////////////////// + // with nothing in session, make sure we find nothing // + //////////////////////////////////////////////////////// + assertEquals(0, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // homer has a car shared with him and 2 things he owns himself - so w/ only his userId in session (and no groups), should find those 3 // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, HOMER_ID); + assertEquals(3, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + + ////////////////////////////////////////////////////////////////////// + // add a group that matches nothing now, just to ensure same result // + ////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(GROUP_ID_KEY_TYPE, BOGUS_GROUP_ID); + assertEquals(3, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // now put homer's groups in the session as well - and we should find the 3 from above, plus a shared family asset and shared power-plant asset // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, HOMER_ID); + QContext.getQSession().withSecurityKeyValue(GROUP_ID_KEY_TYPE, SIMPSONS_ID); + QContext.getQSession().withSecurityKeyValue(GROUP_ID_KEY_TYPE, POWER_PLANT_ID); + assertEquals(5, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testAllAccessKeys() throws QException + { + /////////////////////////////////////////////////////////////// + // with user-id all access key, should get all asset records // + /////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(USER_ID_ALL_ACCESS_KEY_TYPE, true); + assertEquals(7, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + } + } diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java deleted file mode 100644 index 212ca1a5..00000000 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/AssetAudienceInt.java +++ /dev/null @@ -1,226 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2024. 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.sharing.model; - - -import java.time.Instant; -import com.kingsrook.qqq.backend.core.model.data.QField; -import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; - - -/******************************************************************************* - ** QRecord Entity for AssetAudienceInt table - *******************************************************************************/ -public class AssetAudienceInt extends QRecordEntity -{ - public static final String TABLE_NAME = "AssetAudienceInt"; - - @QField(isEditable = false) - private Integer id; - - @QField(isEditable = false) - private Instant createDate; - - @QField(isEditable = false) - private Instant modifyDate; - - @QField(possibleValueSourceName = Asset.TABLE_NAME) - private Integer assetId; - - @QField(possibleValueSourceName = Audience.TABLE_NAME) - private Integer audienceId; - - - /******************************************************************************* - ** Default constructor - *******************************************************************************/ - public AssetAudienceInt() - { - } - - - - /******************************************************************************* - ** Constructor that takes a QRecord - *******************************************************************************/ - public AssetAudienceInt(QRecord record) - { - populateFromQRecord(record); - } - - - /******************************************************************************* - ** Getter for id - *******************************************************************************/ - public Integer getId() - { - return (this.id); - } - - - - /******************************************************************************* - ** Setter for id - *******************************************************************************/ - public void setId(Integer id) - { - this.id = id; - } - - - - /******************************************************************************* - ** Fluent setter for id - *******************************************************************************/ - public AssetAudienceInt withId(Integer id) - { - this.id = id; - return (this); - } - - - - /******************************************************************************* - ** Getter for createDate - *******************************************************************************/ - public Instant getCreateDate() - { - return (this.createDate); - } - - - - /******************************************************************************* - ** Setter for createDate - *******************************************************************************/ - public void setCreateDate(Instant createDate) - { - this.createDate = createDate; - } - - - - /******************************************************************************* - ** Fluent setter for createDate - *******************************************************************************/ - public AssetAudienceInt withCreateDate(Instant createDate) - { - this.createDate = createDate; - return (this); - } - - - - /******************************************************************************* - ** Getter for modifyDate - *******************************************************************************/ - public Instant getModifyDate() - { - return (this.modifyDate); - } - - - - /******************************************************************************* - ** Setter for modifyDate - *******************************************************************************/ - public void setModifyDate(Instant modifyDate) - { - this.modifyDate = modifyDate; - } - - - - /******************************************************************************* - ** Fluent setter for modifyDate - *******************************************************************************/ - public AssetAudienceInt withModifyDate(Instant modifyDate) - { - this.modifyDate = modifyDate; - return (this); - } - - - - /******************************************************************************* - ** Getter for assetId - *******************************************************************************/ - public Integer getAssetId() - { - return (this.assetId); - } - - - - /******************************************************************************* - ** Setter for assetId - *******************************************************************************/ - public void setAssetId(Integer assetId) - { - this.assetId = assetId; - } - - - - /******************************************************************************* - ** Fluent setter for assetId - *******************************************************************************/ - public AssetAudienceInt withAssetId(Integer assetId) - { - this.assetId = assetId; - return (this); - } - - - - /******************************************************************************* - ** Getter for audienceId - *******************************************************************************/ - public Integer getAudienceId() - { - return (this.audienceId); - } - - - - /******************************************************************************* - ** Setter for audienceId - *******************************************************************************/ - public void setAudienceId(Integer audienceId) - { - this.audienceId = audienceId; - } - - - - /******************************************************************************* - ** Fluent setter for audienceId - *******************************************************************************/ - public AssetAudienceInt withAudienceId(Integer audienceId) - { - this.audienceId = audienceId; - return (this); - } - - -} diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Audience.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/SharedAsset.java similarity index 80% rename from qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Audience.java rename to qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/SharedAsset.java index 88216a46..f0636525 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/Audience.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/SharedAsset.java @@ -29,11 +29,11 @@ import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; /******************************************************************************* - ** QRecord Entity for Audience table + ** QRecord Entity for SharedAsset table *******************************************************************************/ -public class Audience extends QRecordEntity +public class SharedAsset extends QRecordEntity { - public static final String TABLE_NAME = "Audience"; + public static final String TABLE_NAME = "SharedAsset"; @QField(isEditable = false) private Integer id; @@ -44,21 +44,20 @@ public class Audience extends QRecordEntity @QField(isEditable = false) private Instant modifyDate; - @QField() - private String type; + @QField(possibleValueSourceName = Asset.TABLE_NAME) + private Integer assetId; - @QField() - private String name; - - @QField() - private String securityKey; + @QField(possibleValueSourceName = User.TABLE_NAME) + private Integer userId; + @QField(possibleValueSourceName = Group.TABLE_NAME) + private Integer groupId; /******************************************************************************* ** Default constructor *******************************************************************************/ - public Audience() + public SharedAsset() { } @@ -67,7 +66,7 @@ public class Audience extends QRecordEntity /******************************************************************************* ** Constructor that takes a QRecord *******************************************************************************/ - public Audience(QRecord record) + public SharedAsset(QRecord record) { populateFromQRecord(record); } @@ -96,7 +95,7 @@ public class Audience extends QRecordEntity /******************************************************************************* ** Fluent setter for id *******************************************************************************/ - public Audience withId(Integer id) + public SharedAsset withId(Integer id) { this.id = id; return (this); @@ -127,7 +126,7 @@ public class Audience extends QRecordEntity /******************************************************************************* ** Fluent setter for createDate *******************************************************************************/ - public Audience withCreateDate(Instant createDate) + public SharedAsset withCreateDate(Instant createDate) { this.createDate = createDate; return (this); @@ -158,7 +157,7 @@ public class Audience extends QRecordEntity /******************************************************************************* ** Fluent setter for modifyDate *******************************************************************************/ - public Audience withModifyDate(Instant modifyDate) + public SharedAsset withModifyDate(Instant modifyDate) { this.modifyDate = modifyDate; return (this); @@ -167,93 +166,94 @@ public class Audience extends QRecordEntity /******************************************************************************* - ** Getter for type + ** Getter for assetId *******************************************************************************/ - public String getType() + public Integer getAssetId() { - return (this.type); + return (this.assetId); } /******************************************************************************* - ** Setter for type + ** Setter for assetId *******************************************************************************/ - public void setType(String type) + public void setAssetId(Integer assetId) { - this.type = type; + this.assetId = assetId; } /******************************************************************************* - ** Fluent setter for type + ** Fluent setter for assetId *******************************************************************************/ - public Audience withType(String type) + public SharedAsset withAssetId(Integer assetId) { - this.type = type; + this.assetId = assetId; + return (this); + } + + + + + /******************************************************************************* + ** Getter for userId + *******************************************************************************/ + public Integer getUserId() + { + return (this.userId); + } + + + + /******************************************************************************* + ** Setter for userId + *******************************************************************************/ + public void setUserId(Integer userId) + { + this.userId = userId; + } + + + + /******************************************************************************* + ** Fluent setter for userId + *******************************************************************************/ + public SharedAsset withUserId(Integer userId) + { + this.userId = userId; return (this); } /******************************************************************************* - ** Getter for name + ** Getter for groupId *******************************************************************************/ - public String getName() + public Integer getGroupId() { - return (this.name); + return (this.groupId); } /******************************************************************************* - ** Setter for name + ** Setter for groupId *******************************************************************************/ - public void setName(String name) + public void setGroupId(Integer groupId) { - this.name = name; + this.groupId = groupId; } /******************************************************************************* - ** Fluent setter for name + ** Fluent setter for groupId *******************************************************************************/ - public Audience withName(String name) + public SharedAsset withGroupId(Integer groupId) { - this.name = name; - return (this); - } - - - - /******************************************************************************* - ** Getter for securityKey - *******************************************************************************/ - public String getSecurityKey() - { - return (this.securityKey); - } - - - - /******************************************************************************* - ** Setter for securityKey - *******************************************************************************/ - public void setSecurityKey(String securityKey) - { - this.securityKey = securityKey; - } - - - - /******************************************************************************* - ** Fluent setter for securityKey - *******************************************************************************/ - public Audience withSecurityKey(String securityKey) - { - this.securityKey = securityKey; + this.groupId = groupId; return (this); } diff --git a/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql b/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql index 3c3e47d0..70a00914 100644 --- a/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql +++ b/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql @@ -22,64 +22,53 @@ DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( - id INTEGER AUTO_INCREMENT PRIMARY KEY, - create_date TIMESTAMP DEFAULT now(), - modify_date TIMESTAMP DEFAULT now(), - username VARCHAR(100) + id INTEGER AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT NOW(), + modify_date TIMESTAMP DEFAULT NOW(), + username VARCHAR(100) ); DROP TABLE IF EXISTS `group`; CREATE TABLE `group` ( - id INTEGER AUTO_INCREMENT PRIMARY KEY, - create_date TIMESTAMP DEFAULT now(), - modify_date TIMESTAMP DEFAULT now(), - name VARCHAR(100), - client_id INTEGER + id INTEGER AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT NOW(), + modify_date TIMESTAMP DEFAULT NOW(), + name VARCHAR(100), + client_id INTEGER ); DROP TABLE IF EXISTS `client`; CREATE TABLE `client` ( - id INTEGER AUTO_INCREMENT PRIMARY KEY, - create_date TIMESTAMP DEFAULT now(), - modify_date TIMESTAMP DEFAULT now(), - name VARCHAR(100) -); - - -DROP TABLE IF EXISTS audience; -CREATE TABLE audience -( - id INT AUTO_INCREMENT primary key , - create_date TIMESTAMP DEFAULT now(), - modify_date TIMESTAMP DEFAULT now(), - type VARCHAR(50), - name VARCHAR(100), - security_key VARCHAR(100) + id INTEGER AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT NOW(), + modify_date TIMESTAMP DEFAULT NOW(), + name VARCHAR(100) ); DROP TABLE IF EXISTS asset; CREATE TABLE asset ( - id INT AUTO_INCREMENT primary key , - create_date TIMESTAMP DEFAULT now(), - modify_date TIMESTAMP DEFAULT now(), - name VARCHAR(100), - user_id INTEGER + id INT AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT NOW(), + modify_date TIMESTAMP DEFAULT NOW(), + name VARCHAR(100), + user_id INTEGER ); -DROP TABLE IF EXISTS asset_audience_int; -CREATE TABLE asset_audience_int +DROP TABLE IF EXISTS shared_asset; +CREATE TABLE shared_asset ( - id INT AUTO_INCREMENT primary key , - create_date TIMESTAMP DEFAULT now(), - modify_date TIMESTAMP DEFAULT now(), - asset_id INTEGER, - audience_id INTEGER + id INT AUTO_INCREMENT PRIMARY KEY, + create_date TIMESTAMP DEFAULT NOW(), + modify_date TIMESTAMP DEFAULT NOW(), + asset_id INTEGER, + user_id INTEGER, + group_id INTEGER ); From 05b1c70a1b96cb25bfed004af5d24bbc396121b8 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:08:22 -0500 Subject: [PATCH 31/58] CE-882 Placeholder TODO re: multiRecordSecurityLocks --- .../tables/helpers/ValidateRecordSecurityLockHelper.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java index 1f235999..50a1f730 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java @@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; import com.kingsrook.qqq.backend.core.model.metadata.security.NullValueBehaviorUtil; import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; @@ -273,6 +274,13 @@ public class ValidateRecordSecurityLockHelper //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// for(RecordSecurityLock recordSecurityLock : recordSecurityLocks) { + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) + { + // todo do do! + LOG.warn("Totally not ready to handle multiRecordSecurityLock in here!!", new Throwable()); + continue; + } + QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) { From 96bd60e7953337c48fb5f113a3260a558437d3ce Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:14:41 -0500 Subject: [PATCH 32/58] CE-882 Checkstyle --- .../metadata/security/RecordSecurityLock.java | 16 +++++++++------- .../rdbms/actions/AbstractRDBMSAction.java | 4 ++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java index 37d34cd1..9902307a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java @@ -51,6 +51,8 @@ public class RecordSecurityLock implements Cloneable private LockScope lockScope = LockScope.READ_AND_WRITE; + + /******************************************************************************* ** *******************************************************************************/ @@ -294,13 +296,13 @@ public class RecordSecurityLock implements Cloneable @Override public String toString() { - return "RecordSecurityLock{" + - "securityKeyType='" + securityKeyType + '\'' + - ", fieldName='" + fieldName + '\'' + - ", joinNameChain=" + joinNameChain + - ", nullValueBehavior=" + nullValueBehavior + - ", lockScope=" + lockScope + - '}'; + return "RecordSecurityLock{" + + "securityKeyType='" + securityKeyType + '\'' + + ", fieldName='" + fieldName + '\'' + + ", joinNameChain=" + joinNameChain + + ", nullValueBehavior=" + nullValueBehavior + + ", lockScope=" + lockScope + + '}'; } } 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 31d4092f..b9c8a8da 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 @@ -603,6 +603,10 @@ public abstract class AbstractRDBMSAction expectedNoOfParams = 0; break; } + default: + { + throw new IllegalArgumentException("Unexpected operator: " + criterion.getOperator()); + } } if(expectedNoOfParams != null) From 7fbe2e643e3c8ccc8afee071be2b2d5c71ab35c2 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:15:46 -0500 Subject: [PATCH 33/58] CE-882 change operator switch to new-style --- .../rdbms/actions/AbstractRDBMSAction.java | 76 ++++++------------- 1 file changed, 24 insertions(+), 52 deletions(-) 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 b9c8a8da..64ffef93 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 @@ -423,25 +423,22 @@ public abstract class AbstractRDBMSAction Integer expectedNoOfParams = null; switch(criterion.getOperator()) { - case EQUALS: + case EQUALS -> { clause += " = ?"; expectedNoOfParams = 1; - break; } - case NOT_EQUALS: + case NOT_EQUALS -> { clause += " != ?"; expectedNoOfParams = 1; - break; } - case NOT_EQUALS_OR_IS_NULL: + case NOT_EQUALS_OR_IS_NULL -> { clause += " != ? OR " + column + " IS NULL "; expectedNoOfParams = 1; - break; } - case IN: + case IN -> { if(values.isEmpty()) { @@ -454,9 +451,8 @@ public abstract class AbstractRDBMSAction { clause += " IN (" + values.stream().map(x -> "?").collect(Collectors.joining(",")) + ")"; } - break; } - case IS_NULL_OR_IN: + case IS_NULL_OR_IN -> { clause += " IS NULL "; @@ -464,9 +460,8 @@ public abstract class AbstractRDBMSAction { clause += " OR " + column + " IN (" + values.stream().map(x -> "?").collect(Collectors.joining(",")) + ")"; } - break; } - case NOT_IN: + case NOT_IN -> { if(values.isEmpty()) { @@ -479,87 +474,74 @@ public abstract class AbstractRDBMSAction { clause += " NOT IN (" + values.stream().map(x -> "?").collect(Collectors.joining(",")) + ")"; } - break; } - case LIKE: + case LIKE -> { clause += " LIKE ?"; expectedNoOfParams = 1; - break; } - case NOT_LIKE: + case NOT_LIKE -> { clause += " NOT LIKE ?"; expectedNoOfParams = 1; - break; } - case STARTS_WITH: + case STARTS_WITH -> { clause += " LIKE ?"; ActionHelper.editFirstValue(values, (s -> s + "%")); expectedNoOfParams = 1; - break; } - case ENDS_WITH: + case ENDS_WITH -> { clause += " LIKE ?"; ActionHelper.editFirstValue(values, (s -> "%" + s)); expectedNoOfParams = 1; - break; } - case CONTAINS: + case CONTAINS -> { clause += " LIKE ?"; ActionHelper.editFirstValue(values, (s -> "%" + s + "%")); expectedNoOfParams = 1; - break; } - case NOT_STARTS_WITH: + case NOT_STARTS_WITH -> { clause += " NOT LIKE ?"; ActionHelper.editFirstValue(values, (s -> s + "%")); expectedNoOfParams = 1; - break; } - case NOT_ENDS_WITH: + case NOT_ENDS_WITH -> { clause += " NOT LIKE ?"; ActionHelper.editFirstValue(values, (s -> "%" + s)); expectedNoOfParams = 1; - break; } - case NOT_CONTAINS: + case NOT_CONTAINS -> { clause += " NOT LIKE ?"; ActionHelper.editFirstValue(values, (s -> "%" + s + "%")); expectedNoOfParams = 1; - break; } - case LESS_THAN: + case LESS_THAN -> { clause += " < ?"; expectedNoOfParams = 1; - break; } - case LESS_THAN_OR_EQUALS: + case LESS_THAN_OR_EQUALS -> { clause += " <= ?"; expectedNoOfParams = 1; - break; } - case GREATER_THAN: + case GREATER_THAN -> { clause += " > ?"; expectedNoOfParams = 1; - break; } - case GREATER_THAN_OR_EQUALS: + case GREATER_THAN_OR_EQUALS -> { clause += " >= ?"; expectedNoOfParams = 1; - break; } - case IS_BLANK: + case IS_BLANK -> { clause += " IS NULL"; if(field.getType().isStringLike()) @@ -567,9 +549,8 @@ public abstract class AbstractRDBMSAction clause += " OR " + column + " = ''"; } expectedNoOfParams = 0; - break; } - case IS_NOT_BLANK: + case IS_NOT_BLANK -> { clause += " IS NOT NULL"; if(field.getType().isStringLike()) @@ -577,35 +558,26 @@ public abstract class AbstractRDBMSAction clause += " AND " + column + " != ''"; } expectedNoOfParams = 0; - break; } - case BETWEEN: + case BETWEEN -> { clause += " BETWEEN ? AND ?"; expectedNoOfParams = 2; - break; } - case NOT_BETWEEN: + case NOT_BETWEEN -> { clause += " NOT BETWEEN ? AND ?"; expectedNoOfParams = 2; - break; } - case TRUE: + case TRUE -> { clause = " 1 = 1 "; expectedNoOfParams = 0; - break; } - case FALSE: + case FALSE -> { clause = " 0 = 1 "; expectedNoOfParams = 0; - break; - } - default: - { - throw new IllegalArgumentException("Unexpected operator: " + criterion.getOperator()); } } From c01a96d4c209475b47e0235d6b341aaad05d9583 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:15:57 -0500 Subject: [PATCH 34/58] CE-882 Remove removed withHint calls --- .../backend/module/rdbms/sharing/SharingMetaDataProvider.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java index 51b1890c..57a94c9c 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java @@ -111,11 +111,9 @@ public class SharingMetaDataProvider .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) .withLock(new RecordSecurityLock() .withSecurityKeyType(USER_ID_KEY_TYPE) - .withHint(RecordSecurityLock.QueryHint.DO_NOT_PUT_CRITERIA_IN_JOIN_ON) .withFieldName("userId")) .withLock(new RecordSecurityLock() .withSecurityKeyType(GROUP_ID_KEY_TYPE) - .withHint(RecordSecurityLock.QueryHint.DO_NOT_PUT_CRITERIA_IN_JOIN_ON) .withFieldName("groupId")) )); QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(SharedAsset.TABLE_NAME)); From 674b090ba3a6c5bd2a70000018bd922653705989 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 25 Apr 2024 12:20:44 -0500 Subject: [PATCH 35/58] CE-882 Checkstyle --- .../qqq/backend/module/rdbms/actions/AbstractRDBMSAction.java | 1 + 1 file changed, 1 insertion(+) 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 64ffef93..64519687 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 @@ -579,6 +579,7 @@ public abstract class AbstractRDBMSAction clause = " 0 = 1 "; expectedNoOfParams = 0; } + default -> throw new IllegalStateException("Unexpected operator: " + criterion.getOperator()); } if(expectedNoOfParams != null) From 3765f6351c963b66485faa533f684e08d4283027 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 10:53:21 -0500 Subject: [PATCH 36/58] CE-882 Add withPrimaryKey method --- .../actions/tables/delete/DeleteInput.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java index 3945246e..c7a92518 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/delete/DeleteInput.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.delete; import java.io.Serializable; +import java.util.ArrayList; import java.util.List; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; @@ -139,6 +140,24 @@ public class DeleteInput extends AbstractTableActionInput + /******************************************************************************* + ** Fluently add 1 primary key to the delete input + ** + *******************************************************************************/ + public DeleteInput withPrimaryKey(Serializable primaryKey) + { + if(primaryKeys == null) + { + primaryKeys = new ArrayList<>(); + } + + primaryKeys.add(primaryKey); + + return (this); + } + + + /******************************************************************************* ** Setter for ids ** From 7e27c7a89a3d0f76e119ae40e44f80d365848429 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 10:54:34 -0500 Subject: [PATCH 37/58] CE-882 add filterForWriteLockTree --- .../security/RecordSecurityLockFilters.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java index e5749087..4d5ad71b 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.metadata.security; import java.util.List; +import java.util.Set; /******************************************************************************* @@ -50,12 +51,36 @@ public class RecordSecurityLockFilters ** filter a list of locks so that we only see the ones that apply to reads. *******************************************************************************/ public static MultiRecordSecurityLock filterForReadLockTree(List recordSecurityLocks) + { + return filterForLockTree(recordSecurityLocks, Set.of(RecordSecurityLock.LockScope.READ_AND_WRITE)); + } + + + + /******************************************************************************* + ** filter a list of locks so that we only see the ones that apply to writes. + *******************************************************************************/ + public static MultiRecordSecurityLock filterForWriteLockTree(List recordSecurityLocks) + { + return filterForLockTree(recordSecurityLocks, Set.of(RecordSecurityLock.LockScope.READ_AND_WRITE, RecordSecurityLock.LockScope.WRITE)); + } + + + + /******************************************************************************* + ** filter a list of locks so that we only see the ones that apply to any of the + ** input set of scopes. + *******************************************************************************/ + private static MultiRecordSecurityLock filterForLockTree(List recordSecurityLocks, Set allowedScopes) { if(recordSecurityLocks == null) { return (null); } + ////////////////////////////////////////////////////////////// + // at the top-level we build a multi-lock with AND operator // + ////////////////////////////////////////////////////////////// MultiRecordSecurityLock result = new MultiRecordSecurityLock(); result.setOperator(MultiRecordSecurityLock.BooleanOperator.AND); @@ -69,7 +94,7 @@ public class RecordSecurityLockFilters } else { - if(RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) + if(allowedScopes.contains(recordSecurityLock.getLockScope())) { result.withLock(recordSecurityLock); } From 6911be1d52a218013101d5ef76dc68450fab5bfa Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 10:55:37 -0500 Subject: [PATCH 38/58] CE-882 add Insert/Update/Delete tests --- .../module/rdbms/sharing/SharingTest.java | 239 +++++++++++++++++- 1 file changed, 230 insertions(+), 9 deletions(-) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java index 3a27f1b3..8d9e00bd 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java @@ -22,30 +22,46 @@ package com.kingsrook.qqq.backend.module.rdbms.sharing; +import java.sql.Connection; import java.sql.SQLException; +import java.time.Instant; import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; 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.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.metadata.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.module.rdbms.TestUtils; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Asset; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Group; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.SharedAsset; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.GROUP_ID_ALL_ACCESS_KEY_TYPE; import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.GROUP_ID_KEY_TYPE; import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.USER_ID_ALL_ACCESS_KEY_TYPE; import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.USER_ID_KEY_TYPE; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -57,11 +73,11 @@ public class SharingTest ////////////// // user ids // ////////////// - public static final int HOMER_ID = 1; - public static final int MARGE_ID = 2; - public static final int BART_ID = 3; - public static final int LISA_ID = 4; - public static final int BURNS_ID = 5; + public static final int HOMER_ID = 1; + public static final int MARGE_ID = 2; + public static final int BART_ID = 3; + public static final int LISA_ID = 4; + public static final int BURNS_ID = 5; /////////////// // group ids // @@ -137,7 +153,7 @@ public class SharingTest ** *******************************************************************************/ @Test - void testAssetWithUserIdOnlySecurityKey() throws QException + void testQueryAssetWithUserIdOnlySecurityKey() throws QException { //////////////////////////////////////////////////////////////////// // update the asset table to change its lock to only be on userId // @@ -183,7 +199,7 @@ public class SharingTest ** but this test is here as we build up making a more complex lock like that. *******************************************************************************/ @Test - void testSharedAssetDirectly() throws QException + void testQuerySharedAssetDirectly() throws QException { //////////////////////////////////////////////////////// // with nothing in session, make sure we find nothing // @@ -209,12 +225,13 @@ public class SharingTest } + /******************************************************************************* ** real-world use-case (e.g., why sharing concept exists) - query the asset table ** *******************************************************************************/ @Test - void testAssetsWithLockThroughSharing() throws QException, SQLException + void testQueryAssetsWithLockThroughSharing() throws QException, SQLException { //////////////////////////////////////////////////////// // with nothing in session, make sure we find nothing // @@ -250,7 +267,7 @@ public class SharingTest ** *******************************************************************************/ @Test - void testAllAccessKeys() throws QException + void testQueryAllAccessKeys() throws QException { /////////////////////////////////////////////////////////////// // with user-id all access key, should get all asset records // @@ -258,6 +275,210 @@ public class SharingTest QContext.getQSession().withSecurityKeyValues(new HashMap<>()); QContext.getQSession().withSecurityKeyValue(USER_ID_ALL_ACCESS_KEY_TYPE, true); assertEquals(7, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + + ////////////////////////////////////////////////////////////////////////////////////////////// + // with group-id all access key... // + // the original thought was, that we should get all assets which are shared to any group // + // but the code that we first wrote generates SQL w/ an OR (1=1) clause, meaning we get all // + // assets, which makes some sense too, so we'll go with that for now... // + ////////////////////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(GROUP_ID_ALL_ACCESS_KEY_TYPE, true); + assertEquals(7, new QueryAction().execute(new QueryInput(Asset.TABLE_NAME)).getRecords().size()); + } + + + + /******************************************************************************* + ** if I'm only able to access user 1 and 2, I shouldn't be able to share to user 3 + *******************************************************************************/ + @Test + void testInsertUpdateDeleteShareUserIdKey() throws QException, SQLException + { + SharedAsset recordToInsert = new SharedAsset().withUserId(3).withAssetId(6); + + ///////////////////////////////////////// + // empty set of keys should give error // + ///////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + InsertOutput insertOutput = new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntity(recordToInsert)); + assertThat(insertOutput.getRecords().get(0).getErrors()).isNotEmpty(); + + ///////////////////////////////////////////// + // mis-matched keys should give same error // + ///////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 1); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 2); + insertOutput = new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntity(recordToInsert)); + assertThat(insertOutput.getRecords().get(0).getErrors()).isNotEmpty(); + + ///////////////////////////////////////////////////////// + // then if I get user 3, I can insert the share for it // + ///////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 3); + insertOutput = new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntity(recordToInsert)); + assertThat(insertOutput.getRecords().get(0).getErrors()).isEmpty(); + + ///////////////////////////////////////// + // get ready for a sequence of updates // + ///////////////////////////////////////// + Integer shareId = insertOutput.getRecords().get(0).getValueInteger("id"); + Supplier makeRecordToUpdate = () -> new QRecord().withValue("id", shareId).withValue("modifyDate", Instant.now()); + + /////////////////////////////////////////////////////////////////////////////// + // now w/o user 3 in my session, I shouldn't be allowed to update that share // + // start w/ empty security keys // + /////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + UpdateOutput updateOutput = new UpdateAction().execute(new UpdateInput(SharedAsset.TABLE_NAME).withRecord(makeRecordToUpdate.get())); + assertThat(updateOutput.getRecords().get(0).getErrors()) + .anyMatch(e -> e.getMessage().contains("No record was found")); // because w/o the key, you can't even see it. + + ///////////////////////////////////////////// + // mis-matched keys should give same error // + ///////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 1); + updateOutput = new UpdateAction().execute(new UpdateInput(SharedAsset.TABLE_NAME).withRecord(makeRecordToUpdate.get())); + assertThat(updateOutput.getRecords().get(0).getErrors()) + .anyMatch(e -> e.getMessage().contains("No record was found")); // because w/o the key, you can't even see it. + + ////////////////////////////////////////////////// + // now with user id 3, should be able to update // + ////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 3); + updateOutput = new UpdateAction().execute(new UpdateInput(SharedAsset.TABLE_NAME).withRecord(makeRecordToUpdate.get())); + assertThat(updateOutput.getRecords().get(0).getErrors()).isEmpty(); + + ////////////////////////////////////////////////////////////////////////// + // now see if you can update to a user that you don't have (you can't!) // + ////////////////////////////////////////////////////////////////////////// + updateOutput = new UpdateAction().execute(new UpdateInput(SharedAsset.TABLE_NAME).withRecord(makeRecordToUpdate.get().withValue("userId", 2))); + assertThat(updateOutput.getRecords().get(0).getErrors()).isNotEmpty(); + + /////////////////////////////////////////////////////////////////////// + // Add that user (2) to the session - then the update should succeed // + /////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 2); + updateOutput = new UpdateAction().execute(new UpdateInput(SharedAsset.TABLE_NAME).withRecord(makeRecordToUpdate.get().withValue("userId", 2))); + assertThat(updateOutput.getRecords().get(0).getErrors()).isEmpty(); + + /////////////////////////////////////////////// + // now move on to deletes - first empty keys // + /////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + DeleteOutput deleteOutput = new DeleteAction().execute(new DeleteInput(SharedAsset.TABLE_NAME).withPrimaryKey(shareId)); + assertEquals(0, deleteOutput.getDeletedRecordCount()); // can't even find it, so no error to be reported. + + /////////////////////// + // next mismatch key // + /////////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 1); + deleteOutput = new DeleteAction().execute(new DeleteInput(SharedAsset.TABLE_NAME).withPrimaryKey(shareId)); + assertEquals(0, deleteOutput.getDeletedRecordCount()); // can't even find it, so no error to be reported. + + /////////////////// + // next success! // + /////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, 2); + deleteOutput = new DeleteAction().execute(new DeleteInput(SharedAsset.TABLE_NAME).withPrimaryKey(shareId)); + assertEquals(1, deleteOutput.getDeletedRecordCount()); + } + + + + /******************************************************************************* + ** useful to debug (e.g., to see inside h2). add calls as needed. + *******************************************************************************/ + private void printSQL(String sql) throws SQLException + { + Connection connection = new ConnectionManager().getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend(TestUtils.DEFAULT_BACKEND_NAME)); + List> maps = QueryManager.executeStatementForRows(connection, sql); + System.out.println(sql); + maps.forEach(System.out::println); + } + + + + /******************************************************************************* + ** if I only have access to group 1, make sure I can't share to group 2 + *******************************************************************************/ + @Test + void testInsertShareGroupIdKey() throws QException + { + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(GROUP_ID_KEY_TYPE, 1); + InsertOutput insertOutput = new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntity(new SharedAsset().withGroupId(2).withAssetId(6))); + assertThat(insertOutput.getRecords().get(0).getErrors()).isNotEmpty(); + + ////////////////////////////////////////// + // add group 2, then we can share to it // + ////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(GROUP_ID_KEY_TYPE, 2); + insertOutput = new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntity(new SharedAsset().withGroupId(2).withAssetId(6))); + assertThat(insertOutput.getRecords().get(0).getErrors()).isEmpty(); + } + + + + /******************************************************************************* + ** w/ user-all-access key, can insert shares for any user + *******************************************************************************/ + @Test + void testInsertUpdateDeleteShareUserAllAccessKey() throws QException + { + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(USER_ID_ALL_ACCESS_KEY_TYPE, true); + InsertOutput insertOutput = new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntity(new SharedAsset().withUserId(1).withAssetId(4))); + assertThat(insertOutput.getRecords().get(0).getErrors()).isEmpty(); + + ///////////////////////////////////////// + // get ready for a sequence of updates // + ///////////////////////////////////////// + Integer shareId = insertOutput.getRecords().get(0).getValueInteger("id"); + Supplier makeRecordToUpdate = () -> new QRecord().withValue("id", shareId).withValue("modifyDate", Instant.now()); + + ////////////////////////////////// + // now w/o all-access key, fail // + ////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + UpdateOutput updateOutput = new UpdateAction().execute(new UpdateInput(SharedAsset.TABLE_NAME).withRecord(makeRecordToUpdate.get())); + assertThat(updateOutput.getRecords().get(0).getErrors()) + .anyMatch(e -> e.getMessage().contains("No record was found")); // because w/o the key, you can't even see it. + + /////////////////////////////////////////////////////// + // now with all-access key, should be able to update // + /////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_ALL_ACCESS_KEY_TYPE, true); + updateOutput = new UpdateAction().execute(new UpdateInput(SharedAsset.TABLE_NAME).withRecord(makeRecordToUpdate.get().withValue("userId", 2))); + assertThat(updateOutput.getRecords().get(0).getErrors()).isEmpty(); + + /////////////////////////////////////////////// + // now move on to deletes - first empty keys // + /////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + DeleteOutput deleteOutput = new DeleteAction().execute(new DeleteInput(SharedAsset.TABLE_NAME).withPrimaryKey(shareId)); + assertEquals(0, deleteOutput.getDeletedRecordCount()); // can't even find it, so no error to be reported. + + /////////////////// + // next success! // + /////////////////// + QContext.getQSession().withSecurityKeyValue(USER_ID_ALL_ACCESS_KEY_TYPE, true); + deleteOutput = new DeleteAction().execute(new DeleteInput(SharedAsset.TABLE_NAME).withPrimaryKey(shareId)); + assertEquals(1, deleteOutput.getDeletedRecordCount()); + } + + + + /******************************************************************************* + ** w/ group-all-access key, can insert shares for any group + *******************************************************************************/ + @Test + void testInsertShareGroupAllAccessKey() throws QException + { + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(GROUP_ID_ALL_ACCESS_KEY_TYPE, true); + InsertOutput insertOutput = new InsertAction().execute(new InsertInput(SharedAsset.TABLE_NAME).withRecordEntity(new SharedAsset().withGroupId(1).withAssetId(4))); + assertThat(insertOutput.getRecords().get(0).getErrors()).isEmpty(); } } From 52c1018d5e947ea66bc25f528e65a3bb2f283312 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 14:57:56 -0500 Subject: [PATCH 39/58] CE-882 Initial rewrite validateSecurityFields to handle MultiRecordSecurityLocks. --- .../core/actions/tables/UpdateAction.java | 8 +- .../ValidateRecordSecurityLockHelper.java | 737 ++++++++++++++---- .../ValidateRecordSecurityLockHelperTest.java | 109 +++ 3 files changed, 700 insertions(+), 154 deletions(-) create mode 100644 qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java index 6a92cf80..95d4badc 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java @@ -68,6 +68,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.NotFoundStatusMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.QErrorMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface; @@ -393,7 +394,12 @@ public class UpdateAction QRecord oldRecord = lookedUpRecords.get(value); QFieldType fieldType = table.getField(lock.getFieldName()).getType(); Serializable lockValue = ValueUtils.getValueAsFieldType(fieldType, oldRecord.getValue(lock.getFieldName())); - ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, record, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE); + + List errors = ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE); + if(CollectionUtils.nullSafeHasContents(errors)) + { + errors.forEach(e -> record.addError(e)); + } } } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java index 50a1f730..aa16b4bd 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.actions.tables.helpers; import java.io.Serializable; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,7 +50,9 @@ 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.statusmessages.PermissionDeniedMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.QErrorMessage; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.ListingHash; import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.ValueUtils; @@ -82,160 +85,273 @@ public class ValidateRecordSecurityLockHelper *******************************************************************************/ public static void validateSecurityFields(QTableMetaData table, List records, Action action) throws QException { - List locksToCheck = getRecordSecurityLocks(table, action); - if(CollectionUtils.nullSafeIsEmpty(locksToCheck)) + MultiRecordSecurityLock locksToCheck = getRecordSecurityLocks(table, action); + if(locksToCheck == null || CollectionUtils.nullSafeIsEmpty(locksToCheck.getLocks())) + { + return; + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // we will be relying on primary keys being set in records - but (at least for inserts) // + // we might not have pkeys - so make them up (and clear them out at the end) // + ////////////////////////////////////////////////////////////////////////////////////////// + Map madeUpPrimaryKeys = makeUpPrimaryKeysIfNeeded(records, table); + + //////////////////////////////// + // actually check lock values // + //////////////////////////////// + Map errorRecords = new HashMap<>(); + evaluateRecordLocks(table, records, action, locksToCheck, errorRecords, new ArrayList<>()); + + ///////////////////////////////// + // propagate errors to records // + ///////////////////////////////// + for(RecordWithErrors recordWithErrors : errorRecords.values()) + { + recordWithErrors.propagateErrorsToRecord(locksToCheck); + } + + ///////////////////////////////// + // remove made-up primary keys // + ///////////////////////////////// + String primaryKeyField = table.getPrimaryKeyField(); + for(QRecord record : madeUpPrimaryKeys.values()) + { + record.setValue(primaryKeyField, null); + } + } + + + + /******************************************************************************* + ** For a list of `records` from a `table`, and a given `action`, evaluate a + ** `recordSecurityLock` (which may be a multi-lock) - populating the input map + ** of `errorRecords` - key'ed by primary key value (real or made up), with + ** error messages existing in a tree, with positions matching the multi-lock + ** tree that we're navigating, as tracked by `treePosition`. + ** + ** Recursively processes multi-locks (and the top-level call is always with a + ** multi-lock - as the table's recordLocks list converted to an AND-multi-lock). + ** + ** Of note - for the case of READ_WRITE locks, we're only evaluating the values + ** on the record, to see if they're allowed for us to store (because if we didn't + ** have the key, we wouldn't have been able to read the value (which is verified + ** outside of here, in UpdateAction/DeleteAction). + ** + ** BUT - WRITE locks - in their case, we read the record no matter what, and in + ** here we need to verify we have a key that allows us to WRITE the record. + *******************************************************************************/ + private static void evaluateRecordLocks(QTableMetaData table, List records, Action action, RecordSecurityLock recordSecurityLock, Map errorRecords, List treePosition) throws QException + { + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) + { + ///////////////////////////////////////////// + // for multi-locks, make recursive descent // + ///////////////////////////////////////////// + int i = 0; + for(RecordSecurityLock childLock : CollectionUtils.nonNullList(multiRecordSecurityLock.getLocks())) + { + treePosition.add(i); + evaluateRecordLocks(table, records, action, childLock, errorRecords, treePosition); + treePosition.remove(treePosition.size() - 1); + i++; + } + + return; + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this lock has an all-access key, and the user has that key, then there can't be any errors here, so return early // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); + if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) { return; } //////////////////////////////// - // actually check lock values // + // proceed w/ non-multi locks // //////////////////////////////// - for(RecordSecurityLock recordSecurityLock : locksToCheck) + String primaryKeyField = table.getPrimaryKeyField(); + if(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain())) { - if(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain())) + ////////////////////////////////////////////////////////////////////////////////// + // handle the value being in the table we're inserting/updating (e.g., no join) // + ////////////////////////////////////////////////////////////////////////////////// + QFieldMetaData field = table.getField(recordSecurityLock.getFieldName()); + + for(QRecord record : records) { - ////////////////////////////////////////////////////////////////////////////////// - // handle the value being in the table we're inserting/updating (e.g., no join) // - ////////////////////////////////////////////////////////////////////////////////// - QFieldMetaData field = table.getField(recordSecurityLock.getFieldName()); - - for(QRecord record : records) + if(action.equals(Action.UPDATE) && !record.getValues().containsKey(field.getName()) && RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) { - if(action.equals(Action.UPDATE) && !record.getValues().containsKey(field.getName()) && RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) - { - ///////////////////////////////////////////////////////////////////////////////////////////////////////// - // if this is a read-write lock, then if we have the record, it means we were able to read the record. // - // So if we're not updating the security field, then no error can come from it! // - ///////////////////////////////////////////////////////////////////////////////////////////////////////// - continue; - } + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this is a read-write lock, then if we have the record, it means we were able to read the record. // + // So if we're not updating the security field, then no error can come from it! // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + continue; + } - Serializable recordSecurityValue = record.getValue(field.getName()); - validateRecordSecurityValue(table, record, recordSecurityLock, recordSecurityValue, field.getType(), action); + Serializable recordSecurityValue = record.getValue(field.getName()); + List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action); + if(CollectionUtils.nullSafeHasContents(recordErrors)) + { + errorRecords.computeIfAbsent(record.getValue(primaryKeyField), (k) -> new RecordWithErrors(record)).addAll(recordErrors, treePosition); } } - else + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else look for the joined record - if it isn't found, assume a fail - else validate security value if found // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QJoinMetaData leftMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(0)); + QJoinMetaData rightMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(recordSecurityLock.getJoinNameChain().size() - 1)); + + //////////////////////////////// + // todo probably, but more... // + //////////////////////////////// + // if(leftMostJoin.getLeftTable().equals(table.getName())) + // { + // leftMostJoin = leftMostJoin.flip(); + // } + + QTableMetaData rightMostJoinTable = QContext.getQInstance().getTable(rightMostJoin.getRightTable()); + QTableMetaData leftMostJoinTable = QContext.getQInstance().getTable(leftMostJoin.getLeftTable()); + + for(List inputRecordPage : CollectionUtils.getPages(records, 500)) { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else look for the joined record - if it isn't found, assume a fail - else validate security value if found // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - QJoinMetaData leftMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(0)); - QJoinMetaData rightMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(recordSecurityLock.getJoinNameChain().size() - 1)); - QTableMetaData rightMostJoinTable = QContext.getQInstance().getTable(rightMostJoin.getRightTable()); - QTableMetaData leftMostJoinTable = QContext.getQInstance().getTable(leftMostJoin.getLeftTable()); + //////////////////////////////////////////////////////////////////////////////////////////////// + // set up a query for joined records // + // query will be like (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) // + //////////////////////////////////////////////////////////////////////////////////////////////// + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(leftMostJoin.getLeftTable()); + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + queryInput.setFilter(filter); - for(List inputRecordPage : CollectionUtils.getPages(records, 500)) + for(String joinName : recordSecurityLock.getJoinNameChain()) { - //////////////////////////////////////////////////////////////////////////////////////////////// - // set up a query for joined records // - // query will be like (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) // - //////////////////////////////////////////////////////////////////////////////////////////////// - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(leftMostJoin.getLeftTable()); - QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); - queryInput.setFilter(filter); - - for(String joinName : recordSecurityLock.getJoinNameChain()) + /////////////////////////////////////// + // we don't need the right-most join // + /////////////////////////////////////// + if(!joinName.equals(rightMostJoin.getName())) { - /////////////////////////////////////// - // we don't need the right-most join // - /////////////////////////////////////// - if(!joinName.equals(rightMostJoin.getName())) + queryInput.withQueryJoin(new QueryJoin().withJoinMetaData(QContext.getQInstance().getJoin(joinName)).withSelect(true)); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // foreach input record (in this page), put it in a listing hash, with key = list of join-values // + // e.g., (17,47)=(QRecord1), (18,48)=(QRecord2,QRecord3) // + // also build up the query's sub-filters here (only adding them if they're unique). // + // e.g., 2 order-lines referencing the same orderId don't need to be added to the query twice // + /////////////////////////////////////////////////////////////////////////////////////////////////// + ListingHash, QRecord> inputRecordMapByJoinFields = new ListingHash<>(); + for(QRecord inputRecord : inputRecordPage) + { + List inputRecordJoinValues = new ArrayList<>(); + QQueryFilter subFilter = new QQueryFilter(); + + boolean updatingAnyLockJoinFields = false; + for(JoinOn joinOn : rightMostJoin.getJoinOns()) + { + QFieldType type = rightMostJoinTable.getField(joinOn.getRightField()).getType(); + Serializable inputRecordValue = ValueUtils.getValueAsFieldType(type, inputRecord.getValue(joinOn.getRightField())); + inputRecordJoinValues.add(inputRecordValue); + + // if we have a value in this field (and it's not the primary key), then it means we're updating part of the lock + if(inputRecordValue != null && !joinOn.getRightField().equals(table.getPrimaryKeyField())) { - queryInput.withQueryJoin(new QueryJoin().withJoinMetaData(QContext.getQInstance().getJoin(joinName)).withSelect(true)); + updatingAnyLockJoinFields = true; } + + subFilter.addCriteria(inputRecordValue == null + ? new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.IS_BLANK) + : new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.EQUALS, inputRecordValue)); } - /////////////////////////////////////////////////////////////////////////////////////////////////// - // foreach input record (in this page), put it in a listing hash, with key = list of join-values // - // e.g., (17,47)=(QRecord1), (18,48)=(QRecord2,QRecord3) // - // also build up the query's sub-filters here (only adding them if they're unique). // - // e.g., 2 order-lines referencing the same orderId don't need to be added to the query twice // - /////////////////////////////////////////////////////////////////////////////////////////////////// - ListingHash, QRecord> inputRecordMapByJoinFields = new ListingHash<>(); - for(QRecord inputRecord : inputRecordPage) + ////////////////////////////////// + // todo maybe, some version of? // + ////////////////////////////////// + // if(action.equals(Action.UPDATE) && !updatingAnyLockJoinFields && RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) + // { + // ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // // if this is a read-write lock, then if we have the record, it means we were able to read the record. // + // // So if we're not updating the security field, then no error can come from it! // + // ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // continue; + // } + + if(!inputRecordMapByJoinFields.containsKey(inputRecordJoinValues)) { - List inputRecordJoinValues = new ArrayList<>(); - QQueryFilter subFilter = new QQueryFilter(); - - for(JoinOn joinOn : rightMostJoin.getJoinOns()) - { - QFieldType type = rightMostJoinTable.getField(joinOn.getRightField()).getType(); - Serializable inputRecordValue = ValueUtils.getValueAsFieldType(type, inputRecord.getValue(joinOn.getRightField())); - inputRecordJoinValues.add(inputRecordValue); - - subFilter.addCriteria(inputRecordValue == null - ? new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.IS_BLANK) - : new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.EQUALS, inputRecordValue)); - } - - if(!inputRecordMapByJoinFields.containsKey(inputRecordJoinValues)) - { - //////////////////////////////////////////////////////////////////////////////// - // only add this sub-filter if it's for a list of keys we haven't seen before // - //////////////////////////////////////////////////////////////////////////////// - filter.addSubFilter(subFilter); - } - - inputRecordMapByJoinFields.add(inputRecordJoinValues, inputRecord); + //////////////////////////////////////////////////////////////////////////////// + // only add this sub-filter if it's for a list of keys we haven't seen before // + //////////////////////////////////////////////////////////////////////////////// + filter.addSubFilter(subFilter); } - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // execute the query for joined records - then put them in a map with keys corresponding to the join values // - // e.g., (17,47)=(JoinRecord), (18,48)=(JoinRecord) // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - QueryOutput queryOutput = new QueryAction().execute(queryInput); - Map, QRecord> joinRecordMapByJoinFields = new HashMap<>(); - for(QRecord joinRecord : queryOutput.getRecords()) - { - List joinRecordValues = new ArrayList<>(); - for(JoinOn joinOn : rightMostJoin.getJoinOns()) - { - Serializable joinValue = joinRecord.getValue(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField()); - if(joinValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> !n.contains("."))) - { - joinValue = joinRecord.getValue(joinOn.getLeftField()); - } - joinRecordValues.add(joinValue); - } + inputRecordMapByJoinFields.add(inputRecordJoinValues, inputRecord); + } - joinRecordMapByJoinFields.put(joinRecordValues, joinRecord); + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // execute the query for joined records - then put them in a map with keys corresponding to the join values // + // e.g., (17,47)=(JoinRecord), (18,48)=(JoinRecord) // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QueryOutput queryOutput = new QueryAction().execute(queryInput); + Map, QRecord> joinRecordMapByJoinFields = new HashMap<>(); + for(QRecord joinRecord : queryOutput.getRecords()) + { + List joinRecordValues = new ArrayList<>(); + for(JoinOn joinOn : rightMostJoin.getJoinOns()) + { + Serializable joinValue = joinRecord.getValue(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField()); + if(joinValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> !n.contains("."))) + { + joinValue = joinRecord.getValue(joinOn.getLeftField()); + } + joinRecordValues.add(joinValue); } - ////////////////////////////////////////////////////////////////////////////////////////////////// - // now for each input record, look for its joinRecord - if it isn't found, then this insert // - // isn't allowed. if it is found, then validate its value matches this session's security keys // - ////////////////////////////////////////////////////////////////////////////////////////////////// - for(Map.Entry, List> entry : inputRecordMapByJoinFields.entrySet()) + joinRecordMapByJoinFields.put(joinRecordValues, joinRecord); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // now for each input record, look for its joinRecord - if it isn't found, then this insert // + // isn't allowed. if it is found, then validate its value matches this session's security keys // + ////////////////////////////////////////////////////////////////////////////////////////////////// + for(Map.Entry, List> entry : inputRecordMapByJoinFields.entrySet()) + { + List inputRecordJoinValues = entry.getKey(); + List inputRecords = entry.getValue(); + if(joinRecordMapByJoinFields.containsKey(inputRecordJoinValues)) { - List inputRecordJoinValues = entry.getKey(); - List inputRecords = entry.getValue(); - if(joinRecordMapByJoinFields.containsKey(inputRecordJoinValues)) + QRecord joinRecord = joinRecordMapByJoinFields.get(inputRecordJoinValues); + + String fieldName = recordSecurityLock.getFieldName().replaceFirst(".*\\.", ""); + QFieldMetaData field = leftMostJoinTable.getField(fieldName); + Serializable recordSecurityValue = joinRecord.getValue(fieldName); + if(recordSecurityValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> n.contains("."))) { - QRecord joinRecord = joinRecordMapByJoinFields.get(inputRecordJoinValues); + recordSecurityValue = joinRecord.getValue(recordSecurityLock.getFieldName()); + } - String fieldName = recordSecurityLock.getFieldName().replaceFirst(".*\\.", ""); - QFieldMetaData field = leftMostJoinTable.getField(fieldName); - Serializable recordSecurityValue = joinRecord.getValue(fieldName); - if(recordSecurityValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> n.contains("."))) + for(QRecord inputRecord : inputRecords) + { + List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action); + if(CollectionUtils.nullSafeHasContents(recordErrors)) { - recordSecurityValue = joinRecord.getValue(recordSecurityLock.getFieldName()); - } - - for(QRecord inputRecord : inputRecords) - { - validateRecordSecurityValue(table, inputRecord, recordSecurityLock, recordSecurityValue, field.getType(), action); + errorRecords.computeIfAbsent(inputRecord.getValue(primaryKeyField), (k) -> new RecordWithErrors(inputRecord)).addAll(recordErrors, treePosition); } } - else + } + else + { + for(QRecord inputRecord : inputRecords) { - for(QRecord inputRecord : inputRecords) + if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) { - if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) - { - inputRecord.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record - the referenced " + leftMostJoinTable.getLabel() + " was not found.")); - } + PermissionDeniedMessage error = new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record - the referenced " + leftMostJoinTable.getLabel() + " was not found."); + errorRecords.computeIfAbsent(inputRecord.getValue(primaryKeyField), (k) -> new RecordWithErrors(inputRecord)).add(error, treePosition); } } } @@ -247,52 +363,77 @@ public class ValidateRecordSecurityLockHelper /******************************************************************************* - ** + ** for tracking errors, we use primary keys. add "made up" ones to records + ** if needed (e.g., insert use-case). *******************************************************************************/ - private static List getRecordSecurityLocks(QTableMetaData table, Action action) + private static Map makeUpPrimaryKeysIfNeeded(List records, QTableMetaData table) { - List recordSecurityLocks = CollectionUtils.nonNullList(table.getRecordSecurityLocks()); - List locksToCheck = new ArrayList<>(); - - recordSecurityLocks = switch(action) + String primaryKeyField = table.getPrimaryKeyField(); + Map madeUpPrimaryKeys = new HashMap<>(); + Integer madeUpPrimaryKey = -1; + for(QRecord record : records) { - case INSERT, UPDATE, DELETE -> RecordSecurityLockFilters.filterForWriteLocks(recordSecurityLocks); - case SELECT -> RecordSecurityLockFilters.filterForReadLocks(recordSecurityLocks); + if(record.getValue(primaryKeyField) == null) + { + madeUpPrimaryKeys.put(madeUpPrimaryKey, record); + record.setValue(primaryKeyField, madeUpPrimaryKey); + madeUpPrimaryKey--; + } + } + return madeUpPrimaryKeys; + } + + + + /******************************************************************************* + ** For a given table & action type, convert the table's record locks to a + ** MultiRecordSecurityLock, with only the appropriate lock-scopes being included + ** (e.g., read-locks for selects, write-locks for insert/update/delete). + *******************************************************************************/ + static MultiRecordSecurityLock getRecordSecurityLocks(QTableMetaData table, Action action) + { + List allLocksOnTable = CollectionUtils.nonNullList(table.getRecordSecurityLocks()); + MultiRecordSecurityLock locksOfType = switch(action) + { + case INSERT, UPDATE, DELETE -> RecordSecurityLockFilters.filterForWriteLockTree(allLocksOnTable); + case SELECT -> RecordSecurityLockFilters.filterForReadLockTree(allLocksOnTable); default -> throw (new IllegalArgumentException("Unsupported action: " + action)); }; + if(action.equals(Action.UPDATE)) + { + //////////////////////////////////////////////////////// + // when doing an update, convert all OR's to AND's... // + //////////////////////////////////////////////////////// + updateOperators(locksOfType, MultiRecordSecurityLock.BooleanOperator.AND); + } + //////////////////////////////////////// // if there are no locks, just return // //////////////////////////////////////// - if(CollectionUtils.nullSafeIsEmpty(recordSecurityLocks)) + if(locksOfType == null || CollectionUtils.nullSafeIsEmpty(locksOfType.getLocks())) { return (null); } - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // decide if any locks need checked - where one may not need checked if it has an all-access key, and the user has all-access // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - for(RecordSecurityLock recordSecurityLock : recordSecurityLocks) - { - if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) - { - // todo do do! - LOG.warn("Totally not ready to handle multiRecordSecurityLock in here!!", new Throwable()); - continue; - } + return (locksOfType); + } - QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); - if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) + + + /******************************************************************************* + ** for a full multi-lock tree, set all of the boolean operators to the specified one. + *******************************************************************************/ + private static void updateOperators(MultiRecordSecurityLock multiLock, MultiRecordSecurityLock.BooleanOperator operator) + { + multiLock.setOperator(operator); + for(RecordSecurityLock childLock : multiLock.getLocks()) + { + if(childLock instanceof MultiRecordSecurityLock childMultiLock) { - LOG.trace("Session has " + securityKeyType.getAllAccessKeyName() + " - not checking this lock."); - } - else - { - locksToCheck.add(recordSecurityLock); + updateOperators(childMultiLock, operator); } } - - return (locksToCheck); } @@ -300,7 +441,7 @@ public class ValidateRecordSecurityLockHelper /******************************************************************************* ** *******************************************************************************/ - public static void validateRecordSecurityValue(QTableMetaData table, QRecord record, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action) + public static List validateRecordSecurityValue(QTableMetaData table, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action) { if(recordSecurityValue == null) { @@ -310,7 +451,7 @@ public class ValidateRecordSecurityLockHelper if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) { String lockLabel = CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()) ? recordSecurityLock.getSecurityKeyType() : table.getField(recordSecurityLock.getFieldName()).getLabel(); - record.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record without a value in the field: " + lockLabel)); + return (List.of(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record without a value in the field: " + lockLabel))); } } else @@ -322,15 +463,305 @@ public class ValidateRecordSecurityLockHelper /////////////////////////////////////////////////////////////////////////////////////////////// // avoid telling the user a value from a foreign record that they didn't pass in themselves. // /////////////////////////////////////////////////////////////////////////////////////////////// - record.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record.")); + return (List.of(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record."))); } else { QFieldMetaData field = table.getField(recordSecurityLock.getFieldName()); - record.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record with a value of " + recordSecurityValue + " in the field: " + field.getLabel())); + return (List.of(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record with a value of " + recordSecurityValue + " in the field: " + field.getLabel()))); } } } + return (Collections.emptyList()); + } + + + + /******************************************************************************* + ** Class to track errors that we're associating with a record. + ** + ** More complex than it first seems to be needed, because as we're evaluating + ** locks, we might find some, but based on the boolean condition associated with + ** them, they might not actually be record-level errors. + ** + ** e.g., two locks with an OR relationship - as long as one passes, the record + ** should have no errors. And so-on through the tree of locks/multi-locks. + ** + ** Stores the errors in a tree of ErrorTreeNode objects. + ** + ** References into that tree are achieved via a List of Integer called "tree positions" + ** where each entry in the list denotes the index of the tree node at that level. + ** + ** e.g., given this tree: + **
+    **   A      B
+    **  / \    /|\
+    ** C   D  E F G
+    **     |
+    **     H
+    ** 
+ ** + ** The positions of each node would be: + **
+    ** A: [0]
+    ** B: [1]
+    ** C: [0,0]
+    ** D: [0,1]
+    ** E: [1,0]
+    ** F: [1,1]
+    ** G: [1,2]
+    ** H: [0,1,0]
+    ** 
+ *******************************************************************************/ + static class RecordWithErrors + { + private QRecord record; + private ErrorTreeNode errorTree; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public RecordWithErrors(QRecord record) + { + this.record = record; + } + + + + /******************************************************************************* + ** add a list of errors, for a given list of tree positions + *******************************************************************************/ + public void addAll(List recordErrors, List treePositions) + { + if(errorTree == null) + { + errorTree = new ErrorTreeNode(); + } + + ErrorTreeNode node = errorTree; + for(Integer treePosition : treePositions) + { + if(node.children == null) + { + node.children = new ArrayList<>(treePosition); + } + + while(treePosition >= node.children.size()) + { + node.children.add(null); + } + + if(node.children.get(treePosition) == null) + { + node.children.set(treePosition, new ErrorTreeNode()); + } + + node = node.children.get(treePosition); + } + + if(node.errors == null) + { + node.errors = new ArrayList<>(); + } + node.errors.addAll(recordErrors); + } + + + + /******************************************************************************* + ** add a single error to a given tree-position + *******************************************************************************/ + public void add(QErrorMessage error, List treePositions) + { + addAll(List.of(error), treePositions); + } + + + + /******************************************************************************* + ** after the tree of errors has been built - walk a lock-tree (locksToCheck) + ** and resolve boolean operations, to get a final list of errors (possibly empty) + ** to put on the record. + *******************************************************************************/ + public void propagateErrorsToRecord(MultiRecordSecurityLock locksToCheck) + { + List errors = recursivePropagation(locksToCheck, new ArrayList<>()); + + if(CollectionUtils.nullSafeHasContents(errors)) + { + errors.forEach(e -> record.addError(e)); + } + } + + + + /******************************************************************************* + ** recursive implementation of the propagation method - e.g., walk tree applying + ** boolean logic. + *******************************************************************************/ + private List recursivePropagation(MultiRecordSecurityLock locksToCheck, List treePositions) + { + ////////////////////////////////////////////////////////////////// + // build a list of errors at this level (and deeper levels too) // + ////////////////////////////////////////////////////////////////// + List errorsFromThisLevel = new ArrayList<>(); + + int i = 0; + for(RecordSecurityLock lock : locksToCheck.getLocks()) + { + List errorsFromThisLock; + + treePositions.add(i); + if(lock instanceof MultiRecordSecurityLock childMultiLock) + { + errorsFromThisLock = recursivePropagation(childMultiLock, treePositions); + } + else + { + errorsFromThisLock = getErrorsFromTree(treePositions); + } + + errorsFromThisLevel.addAll(errorsFromThisLock); + + treePositions.remove(treePositions.size() - 1); + i++; + } + + if(MultiRecordSecurityLock.BooleanOperator.AND.equals(locksToCheck.getOperator())) + { + ////////////////////////////////////////////////////////////// + // for an AND - if there were ANY errors, then return them. // + ////////////////////////////////////////////////////////////// + if(!errorsFromThisLevel.isEmpty()) + { + return (errorsFromThisLevel); + } + } + else // OR + { + ////////////////////////////////////////////////////////// + // for an OR - only return if ALL conditions had errors // + ////////////////////////////////////////////////////////// + if(errorsFromThisLevel.size() == locksToCheck.getLocks().size()) + { + return (errorsFromThisLevel); // todo something smarter? + } + } + + /////////////////////////////////// + // else - no errors - empty list // + /////////////////////////////////// + return Collections.emptyList(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List getErrorsFromTree(List treePositions) + { + ErrorTreeNode node = errorTree; + + for(Integer treePosition : treePositions) + { + if(node.children == null) + { + return Collections.emptyList(); + } + + if(treePosition >= node.children.size()) + { + return Collections.emptyList(); + } + + if(node.children.get(treePosition) == null) + { + return Collections.emptyList(); + } + + node = node.children.get(treePosition); + } + + if(node.errors == null) + { + return Collections.emptyList(); + } + + return node.errors; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String toString() + { + try + { + return JsonUtils.toPrettyJson(this); + } + catch(Exception e) + { + return "error in toString"; + } + } + } + + + + /******************************************************************************* + ** tree node used by RecordWithErrors + *******************************************************************************/ + static class ErrorTreeNode + { + private List errors; + private ArrayList children; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String toString() + { + try + { + return JsonUtils.toPrettyJson(this); + } + catch(Exception e) + { + return "error in toString"; + } + } + + + + /******************************************************************************* + ** Getter for errors - only here for Jackson/toString + ** + *******************************************************************************/ + public List getErrors() + { + return errors; + } + + + + /******************************************************************************* + ** Getter for children - only here for Jackson/toString + ** + *******************************************************************************/ + public ArrayList getChildren() + { + return children; + } } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java new file mode 100644 index 00000000..6186ad4e --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java @@ -0,0 +1,109 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.tables.helpers; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper.RecordWithErrors; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock.BooleanOperator.AND; + + +/******************************************************************************* + ** Unit test for ValidateRecordSecurityLockHelper + *******************************************************************************/ +class ValidateRecordSecurityLockHelperTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRecordWithErrors() + { + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0"), List.of(0)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withOperator(AND).withLocks(List.of(new RecordSecurityLock()))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("1"), List.of(1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock()))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0"), List.of(0)); + recordWithErrors.add(new BadInputStatusMessage("1"), List.of(1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock()))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("1,1"), List.of(1, 1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of( + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())), + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())) + ))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0,0"), List.of(0, 0)); + recordWithErrors.add(new BadInputStatusMessage("1,1"), List.of(1, 1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of( + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())), + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())) + ))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0"), List.of(0)); + recordWithErrors.add(new BadInputStatusMessage("1,1"), List.of(1, 1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of( + new RecordSecurityLock(), + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())) + ))); + System.out.println("----------------------------------------------------------------------------"); + } + } + +} \ No newline at end of file From c72e6ad23d2a820a8441145c148162f6b9fa3b44 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 14:58:23 -0500 Subject: [PATCH 40/58] CE-882 Add reversed join --- .../rdbms/sharing/SharingMetaDataProvider.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java index 57a94c9c..98f822c6 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java @@ -55,6 +55,7 @@ public class SharingMetaDataProvider public static final String GROUP_ID_ALL_ACCESS_KEY_TYPE = "groupIdAllAccessKey"; private static final String ASSET_JOIN_SHARED_ASSET = "assetJoinSharedAsset"; + private static final String SHARED_ASSET_JOIN_ASSET = "sharedAssetJoinAsset"; @@ -93,11 +94,11 @@ public class SharingMetaDataProvider .withLock(new RecordSecurityLock() .withSecurityKeyType(USER_ID_KEY_TYPE) .withFieldName("sharedAsset.userId") - .withJoinNameChain(List.of(ASSET_JOIN_SHARED_ASSET))) + .withJoinNameChain(List.of(SHARED_ASSET_JOIN_ASSET))) .withLock(new RecordSecurityLock() .withSecurityKeyType(GROUP_ID_KEY_TYPE) .withFieldName("sharedAsset.groupId") - .withJoinNameChain(List.of(ASSET_JOIN_SHARED_ASSET))) + .withJoinNameChain(List.of(SHARED_ASSET_JOIN_ASSET))) )); QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Asset.TABLE_NAME)); @@ -154,6 +155,14 @@ public class SharingMetaDataProvider .withType(JoinType.ONE_TO_MANY) .withJoinOn(new JoinOn("id", "assetId")) ); + + qInstance.addJoin(new QJoinMetaData() + .withName(SHARED_ASSET_JOIN_ASSET) + .withLeftTable(SharedAsset.TABLE_NAME) + .withRightTable(Asset.TABLE_NAME) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("assetId", "id")) + ); } } From bd8246ca5ad06af72f9f6fbb5fb8ef385d97bb10 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 14:58:50 -0500 Subject: [PATCH 41/58] CE-882 Add Disabled test that's failing... needs fixed! --- .../module/rdbms/sharing/SharingTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java index 8d9e00bd..c8b7fa20 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java @@ -56,6 +56,7 @@ import com.kingsrook.qqq.backend.module.rdbms.sharing.model.Group; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.SharedAsset; import com.kingsrook.qqq.backend.module.rdbms.sharing.model.User; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.GROUP_ID_ALL_ACCESS_KEY_TYPE; import static com.kingsrook.qqq.backend.module.rdbms.sharing.SharingMetaDataProvider.GROUP_ID_KEY_TYPE; @@ -481,4 +482,33 @@ public class SharingTest assertThat(insertOutput.getRecords().get(0).getErrors()).isEmpty(); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + @Disabled("This needs fixed, but we're committing as-we are to move forwards") + void testUpdateAsset() throws QException + { + //////////////////////////////////////////////////////////////////////////////////////// + // make sure we can't update an Asset if we don't have a key that would let us see it // + //////////////////////////////////////////////////////////////////////////////////////// + { + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + UpdateOutput updateOutput = new UpdateAction().execute(new UpdateInput(Asset.TABLE_NAME).withRecord(new QRecord().withValue("id", 1).withValue("modifyDate", Instant.now()))); + assertThat(updateOutput.getRecords().get(0).getErrors()).isNotEmpty(); + } + + /////////////////////////////////////////////// + // and if we do have a key, we can update it // + /////////////////////////////////////////////// + { + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValue(USER_ID_KEY_TYPE, HOMER_ID); + UpdateOutput updateOutput = new UpdateAction().execute(new UpdateInput(Asset.TABLE_NAME).withRecord(new QRecord().withValue("id", 1).withValue("modifyDate", Instant.now()))); + assertThat(updateOutput.getRecords().get(0).getErrors()).isEmpty(); + } + } + } From 0e8e93bff9e282fda1b63ade9b05e4c98db7634e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 15:01:11 -0500 Subject: [PATCH 42/58] CE-882 Checkstyle --- .../actions/tables/helpers/ValidateRecordSecurityLockHelper.java | 1 + 1 file changed, 1 insertion(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java index aa16b4bd..26a238bc 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java @@ -390,6 +390,7 @@ public class ValidateRecordSecurityLockHelper ** MultiRecordSecurityLock, with only the appropriate lock-scopes being included ** (e.g., read-locks for selects, write-locks for insert/update/delete). *******************************************************************************/ + @SuppressWarnings("checkstyle:Indentation") static MultiRecordSecurityLock getRecordSecurityLocks(QTableMetaData table, Action action) { List allLocksOnTable = CollectionUtils.nonNullList(table.getRecordSecurityLocks()); From 4f0ded6bd6cca8b4632c79ec059acbaa776525a8 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 31 Jan 2024 11:08:26 -0600 Subject: [PATCH 43/58] Start adding overrideIdField to QPossibleValueSource --- .../values/QPossibleValueTranslator.java | 35 ++++++++++++++++--- .../SearchPossibleValueSourceAction.java | 15 ++++++-- .../possiblevalues/QPossibleValueSource.java | 32 +++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QPossibleValueTranslator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QPossibleValueTranslator.java index a6a23d86..6d49cc9a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QPossibleValueTranslator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QPossibleValueTranslator.java @@ -560,20 +560,47 @@ public class QPossibleValueTranslator *******************************************************************************/ private void primePvsCache(String tableName, List possibleValueSources, Collection values) { + String idField = null; for(QPossibleValueSource possibleValueSource : possibleValueSources) { possibleValueCache.putIfAbsent(possibleValueSource.getName(), new HashMap<>()); + String thisPvsIdField; + if(StringUtils.hasContent(possibleValueSource.getOverrideIdField())) + { + thisPvsIdField = possibleValueSource.getOverrideIdField(); + } + else + { + thisPvsIdField = QContext.getQInstance().getTable(tableName).getPrimaryKeyField(); + } + + if(idField == null) + { + idField = thisPvsIdField; + } + else + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // does this ever happen? maybe not... because, like, the list of values probably wouldn't make sense for // + // more than one field in the table... // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(!idField.equals(thisPvsIdField)) + { + for(QPossibleValueSource valueSource : possibleValueSources) + { + primePvsCache(tableName, List.of(valueSource), values); + } + } + } } try { - String primaryKeyField = QContext.getQInstance().getTable(tableName).getPrimaryKeyField(); - for(List page : CollectionUtils.getPages(values, 1000)) { QueryInput queryInput = new QueryInput(); queryInput.setTableName(tableName); - queryInput.setFilter(new QQueryFilter().withCriteria(new QFilterCriteria(primaryKeyField, QCriteriaOperator.IN, page))); + queryInput.setFilter(new QQueryFilter().withCriteria(new QFilterCriteria(idField, QCriteriaOperator.IN, page))); queryInput.setTransaction(getTransaction(tableName)); ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -618,7 +645,7 @@ public class QPossibleValueTranslator /////////////////////////////////////////////////////////////////////////////////// for(QRecord record : queryOutput.getRecords()) { - Serializable pkeyValue = record.getValue(primaryKeyField); + Serializable pkeyValue = record.getValue(idField); for(QPossibleValueSource possibleValueSource : possibleValueSources) { QPossibleValue possibleValue = new QPossibleValue<>(pkeyValue, record.getRecordLabel()); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/SearchPossibleValueSourceAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/SearchPossibleValueSourceAction.java index 7816dba4..9657b2fb 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/SearchPossibleValueSourceAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/SearchPossibleValueSourceAction.java @@ -275,8 +275,19 @@ public class SearchPossibleValueSourceAction queryInput.setFilter(queryFilter); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - List ids = queryOutput.getRecords().stream().map(r -> r.getValue(table.getPrimaryKeyField())).toList(); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + + String fieldName; + if(StringUtils.hasContent(possibleValueSource.getOverrideIdField())) + { + fieldName = possibleValueSource.getOverrideIdField(); + } + else + { + fieldName = table.getPrimaryKeyField(); + } + + List ids = queryOutput.getRecords().stream().map(r -> r.getValue(fieldName)).toList(); List> qPossibleValues = possibleValueTranslator.buildTranslatedPossibleValueList(possibleValueSource, ids); output.setResults(qPossibleValues); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/QPossibleValueSource.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/QPossibleValueSource.java index 60463273..e8fc860b 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/QPossibleValueSource.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/possiblevalues/QPossibleValueSource.java @@ -53,6 +53,7 @@ public class QPossibleValueSource implements TopLevelMetaDataInterface // for type = TABLE // ////////////////////// private String tableName; + private String overrideIdField; private List searchFields; private List orderByFields; @@ -630,4 +631,35 @@ public class QPossibleValueSource implements TopLevelMetaDataInterface qInstance.addPossibleValueSource(this); } + + + /******************************************************************************* + ** Getter for overrideIdField + *******************************************************************************/ + public String getOverrideIdField() + { + return (this.overrideIdField); + } + + + + /******************************************************************************* + ** Setter for overrideIdField + *******************************************************************************/ + public void setOverrideIdField(String overrideIdField) + { + this.overrideIdField = overrideIdField; + } + + + + /******************************************************************************* + ** Fluent setter for overrideIdField + *******************************************************************************/ + public QPossibleValueSource withOverrideIdField(String overrideIdField) + { + this.overrideIdField = overrideIdField; + return (this); + } + } From a3ed016606c5d7afce3ec8a3bce5d0eb6dea3429 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 26 Apr 2024 20:05:31 -0500 Subject: [PATCH 44/58] Checkstyle (from merger...) --- .../core/instances/QInstanceValidator.java | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java index 2df4c4fd..7a9200b2 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java @@ -787,11 +787,11 @@ public class QInstanceValidator { if(assertCondition(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " looks like a join (has a dot), but no joinNameChain was given.")) { - String[] split = fieldName.split("\\."); - String joinTableName = split[0]; - String joinFieldName = split[1]; + String[] split = fieldName.split("\\."); + String joinTableName = split[0]; + String joinFieldName = split[1]; - List joins = new ArrayList<>(); + List joins = new ArrayList<>(); /////////////////////////////////////////////////////////////////////////////////////////////////// // ok - so - the join name chain is going to be like this: // @@ -834,22 +834,23 @@ public class QInstanceValidator assertCondition(Objects.equals(tmpTable.getName(), joinTableName), prefix + "has a joinNameChain doesn't end in the expected table [" + joinTableName + "]"); - assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); - } - } - else - { - if(assertCondition(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " does not look like a join (does not have a dot), but a joinNameChain was given.")) - { - assertNoException(() -> table.getField(fieldName), prefix + "has an unrecognized fieldName: " + fieldName); - } + assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); } } + else + { + if(assertCondition(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " does not look like a join (does not have a dot), but a joinNameChain was given.")) + { + assertNoException(() -> table.getField(fieldName), prefix + "has an unrecognized fieldName: " + fieldName); + } + } + } assertCondition(recordSecurityLock.getNullValueBehavior() != null, prefix + "is missing a nullValueBehavior"); } + /******************************************************************************* ** *******************************************************************************/ From c05a1d58127a00c8634542f1c68044326a85df68 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:28:09 -0500 Subject: [PATCH 45/58] CE-882 Change doesSelectClauseRequireDistinct to work with multilocks --- .../rdbms/actions/AbstractRDBMSAction.java | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) 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 64519687..6c54f5be 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 @@ -27,6 +27,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; @@ -63,6 +64,7 @@ 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.MultiRecordSecurityLock; 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; @@ -70,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.querystats.QueryStat; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import com.kingsrook.qqq.backend.core.utils.memoization.Memoization; import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager; import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; @@ -89,6 +92,9 @@ public abstract class AbstractRDBMSAction protected PreparedStatement statement; protected boolean isCancelled = false; + private static Memoization doesSelectClauseRequireDistinctMemoization = new Memoization() + .withTimeout(Duration.ofDays(365)); + /******************************************************************************* @@ -852,6 +858,7 @@ public abstract class AbstractRDBMSAction } + /******************************************************************************* ** Make it easy (e.g., for tests) to turn on logging of SQL *******************************************************************************/ @@ -938,25 +945,52 @@ public abstract class AbstractRDBMSAction /******************************************************************************* ** method that looks at security lock joins, and if a one-to-many is found where ** the specified field name is on the 'right side' of the join, then a distinct - ** needs added to select clause + ** needs added to select clause. + ** + ** Memoized because it's a lot of gyrations, and it never ever changes for a + ** running server. *******************************************************************************/ protected boolean doesSelectClauseRequireDistinct(QTableMetaData table) { - if(table != null) + if(table == null) { - for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(table.getRecordSecurityLocks()))) + return (false); + } + + return doesSelectClauseRequireDistinctMemoization.getResult(table.getName(), (name) -> + { + MultiRecordSecurityLock multiRecordSecurityLock = RecordSecurityLockFilters.filterForReadLockTree(CollectionUtils.nonNullList(table.getRecordSecurityLocks())); + return doesMultiLockRequireDistinct(multiRecordSecurityLock, table); + }).orElse(false); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private boolean doesMultiLockRequireDistinct(MultiRecordSecurityLock multiRecordSecurityLock, QTableMetaData table) + { + for(RecordSecurityLock recordSecurityLock : multiRecordSecurityLock.getLocks()) + { + if(recordSecurityLock instanceof MultiRecordSecurityLock childMultiLock) { - for(String joinName : CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())) + if(doesMultiLockRequireDistinct(childMultiLock, table)) { - QJoinMetaData joinMetaData = QContext.getQInstance().getJoin(joinName); - if(JoinType.ONE_TO_MANY.equals(joinMetaData.getType()) && !joinMetaData.getRightTable().equals(table.getName())) - { - return (true); - } - else if(JoinType.MANY_TO_ONE.equals(joinMetaData.getType()) && !joinMetaData.getLeftTable().equals(table.getName())) - { - return (true); - } + return (true); + } + } + + for(String joinName : CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())) + { + QJoinMetaData joinMetaData = QContext.getQInstance().getJoin(joinName); + if(JoinType.ONE_TO_MANY.equals(joinMetaData.getType()) && !joinMetaData.getRightTable().equals(table.getName())) + { + return (true); + } + else if(JoinType.MANY_TO_ONE.equals(joinMetaData.getType()) && !joinMetaData.getLeftTable().equals(table.getName())) + { + return (true); } } } From e9cfb671015d12aa075877672d0461056bddd49b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:29:00 -0500 Subject: [PATCH 46/58] CE-882 Add customizer.finalCustomizeSession, to do ... final ... customizaions if needed --- ...thenticationModuleCustomizerInterface.java | 10 ++++++++++ .../Auth0AuthenticationModule.java | 20 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/QAuthenticationModuleCustomizerInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/QAuthenticationModuleCustomizerInterface.java index fd77cd81..01cb9a7a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/QAuthenticationModuleCustomizerInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/QAuthenticationModuleCustomizerInterface.java @@ -52,4 +52,14 @@ public interface QAuthenticationModuleCustomizerInterface ////////// } + /******************************************************************************* + ** + *******************************************************************************/ + default void finalCustomizeSession(QInstance qInstance, QSession qSession) + { + ////////// + // noop // + ////////// + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java index 7d1fc57d..5cd096df 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/authentication/implementations/Auth0AuthenticationModule.java @@ -230,6 +230,14 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface } } + ////////////////////////////////////////////////////////////// + // allow customizer to do custom things here, if so desired // + ////////////////////////////////////////////////////////////// + if(getCustomizer() != null) + { + getCustomizer().finalCustomizeSession(qInstance, qSession); + } + return (qSession); } else if(CollectionUtils.containsKeyWithNonNullValue(context, BASIC_AUTH_KEY)) @@ -284,7 +292,17 @@ public class Auth0AuthenticationModule implements QAuthenticationModuleInterface // try to build session to see if still valid // // then call method to check more session validity // ///////////////////////////////////////////////////// - return buildAndValidateSession(qInstance, accessToken); + QSession qSession = buildAndValidateSession(qInstance, accessToken); + + ////////////////////////////////////////////////////////////// + // allow customizer to do custom things here, if so desired // + ////////////////////////////////////////////////////////////// + if(getCustomizer() != null) + { + getCustomizer().finalCustomizeSession(qInstance, qSession); + } + + return (qSession); } catch(QAuthenticationException qae) { From b5ceb846e204118b14eb8b4a726d4aa924524c23 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:29:15 -0500 Subject: [PATCH 47/58] CE-882 add DuplicateKeyBadInputStatusMessage --- .../core/actions/tables/InsertAction.java | 3 +- .../DuplicateKeyBadInputStatusMessage.java | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/statusmessages/DuplicateKeyBadInputStatusMessage.java diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java index 9215c861..9773d3f0 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java @@ -62,6 +62,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; 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.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.DuplicateKeyBadInputStatusMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface; @@ -414,7 +415,7 @@ public class InsertAction extends AbstractQActionFunction> keyValues = UniqueKeyHelper.getKeyValues(table, uniqueKey, record); if(keyValues.isPresent() && (existingKeys.get(uniqueKey).contains(keyValues.get()) || keysInThisList.get(uniqueKey).contains(keyValues.get()))) { - record.addError(new BadInputStatusMessage("Another record already exists with this " + uniqueKey.getDescription(table))); + record.addError(new DuplicateKeyBadInputStatusMessage("Another record already exists with this " + uniqueKey.getDescription(table))); foundDupe = true; break; } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/statusmessages/DuplicateKeyBadInputStatusMessage.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/statusmessages/DuplicateKeyBadInputStatusMessage.java new file mode 100644 index 00000000..ba26a029 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/statusmessages/DuplicateKeyBadInputStatusMessage.java @@ -0,0 +1,39 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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.model.statusmessages; + + +/******************************************************************************* + ** specialization of bad-input status message, specifically for the case of + ** a duplicated key (e.g., unique-key validation error) + *******************************************************************************/ +public class DuplicateKeyBadInputStatusMessage extends BadInputStatusMessage +{ + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public DuplicateKeyBadInputStatusMessage(String message) + { + super(message); + } +} From 389e7a151514f18ef62e8fbb0b5dff0ee5a0af6f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:29:23 -0500 Subject: [PATCH 48/58] CE-882 Sort results --- .../implementations/sharing/GetSharedRecordsProcess.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java index 3b898f65..5bf42978 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java @@ -25,6 +25,7 @@ package com.kingsrook.qqq.backend.core.processes.implementations.sharing; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -144,7 +145,7 @@ public class GetSharedRecordsProcess implements BackendStep, MetaDataProducerInt boolean foundAudienceType = false; for(ShareableAudienceType audienceType : shareableTableMetaData.getAudienceTypes().values()) { - Serializable audienceId = record.getValueString(audienceType.getFieldName()); + Serializable audienceId = record.getValue(audienceType.getFieldName()); if(audienceId != null) { outputRecord.setValue("audienceType", audienceType.getName()); @@ -228,6 +229,11 @@ public class GetSharedRecordsProcess implements BackendStep, MetaDataProducerInt } } + //////////////////////////// + // sort results by labels // + //////////////////////////// + resultList.sort(Comparator.comparing(r -> r.getValueString("audienceLabel"))); + runBackendStepOutput.addValue("resultList", resultList); } catch(QException qe) From ea28c007c1bcba04cfbce635086855032ee0b37f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:29:50 -0500 Subject: [PATCH 49/58] CE-882 Better error on unique key violation --- .../sharing/InsertSharedRecordProcess.java | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java index 4d449ede..c3ba8688 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.processes.implementations.sharing; import java.io.Serializable; import java.util.List; +import java.util.Map; import java.util.Objects; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; @@ -54,6 +55,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.sharing.ShareableTableMetaD import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider; import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.DuplicateKeyBadInputStatusMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.QErrorMessage; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -113,6 +115,7 @@ public class InsertSharedRecordProcess implements BackendStep, MetaDataProducerI Objects.requireNonNull(audienceIdString, "Missing required input: audienceId"); Objects.requireNonNull(scopeId, "Missing required input: scopeId"); + String assetTableLabel = tableName; try { SharedRecordProcessUtils.AssetTableAndRecord assetTableAndRecord = SharedRecordProcessUtils.getAssetTableAndRecord(tableName, recordIdString); @@ -120,6 +123,7 @@ public class InsertSharedRecordProcess implements BackendStep, MetaDataProducerI ShareableTableMetaData shareableTableMetaData = assetTableAndRecord.shareableTableMetaData(); QRecord assetRecord = assetTableAndRecord.record(); Serializable recordId = assetTableAndRecord.recordId(); + assetTableLabel = assetTableAndRecord.table().getLabel(); SharedRecordProcessUtils.assertRecordOwnership(shareableTableMetaData, assetRecord, "share"); @@ -135,12 +139,26 @@ public class InsertSharedRecordProcess implements BackendStep, MetaDataProducerI /////////////////////////////////////////////////////////////////////////////////////////////// // if we know the audience source-table, then fetch & validate security-wise the audience id // /////////////////////////////////////////////////////////////////////////////////////////////// - Serializable audienceId = audienceIdString; + Serializable audienceId = audienceIdString; + String audienceTableLabel = "audience"; if(StringUtils.hasContent(shareableAudienceType.getSourceTableName())) { QTableMetaData audienceTable = QContext.getQInstance().getTable(shareableAudienceType.getSourceTableName()); - audienceId = ValueUtils.getValueAsFieldType(audienceTable.getField(audienceTable.getPrimaryKeyField()).getType(), audienceIdString); - QRecord audienceRecord = new GetAction().executeForRecord(new GetInput(audienceTable.getName()).withPrimaryKey(audienceId)); + audienceTableLabel = audienceTable.getLabel(); + + GetInput getInput = new GetInput(audienceTable.getName()); + if(StringUtils.hasContent(shareableAudienceType.getSourceTableKeyFieldName())) + { + audienceId = ValueUtils.getValueAsFieldType(audienceTable.getField(shareableAudienceType.getSourceTableKeyFieldName()).getType(), audienceIdString); + getInput.withUniqueKey(Map.of(shareableAudienceType.getSourceTableKeyFieldName(), audienceId)); + } + else + { + audienceId = ValueUtils.getValueAsFieldType(audienceTable.getField(audienceTable.getPrimaryKeyField()).getType(), audienceIdString); + getInput.withPrimaryKey(audienceId); + } + + QRecord audienceRecord = new GetAction().executeForRecord(getInput); if(audienceRecord == null) { throw (new QException("A record could not be found for audience type " + audienceType + ", audience id: " + audienceIdString)); @@ -166,11 +184,15 @@ public class InsertSharedRecordProcess implements BackendStep, MetaDataProducerI if(CollectionUtils.nullSafeHasContents(insertOutput.getRecords().get(0).getErrors())) { QErrorMessage errorMessage = insertOutput.getRecords().get(0).getErrors().get(0); - if(errorMessage instanceof BadInputStatusMessage) + if(errorMessage instanceof DuplicateKeyBadInputStatusMessage) + { + throw (new QUserFacingException("This " + assetTableLabel + " has already been shared with this " + audienceTableLabel)); + } + else if(errorMessage instanceof BadInputStatusMessage) { throw (new QUserFacingException(errorMessage.getMessage())); } - throw (new QException("Error inserting shared record: " + errorMessage.getMessage())); + throw (new QException("Error sharing " + assetTableLabel + ": " + errorMessage.getMessage())); } } catch(QException qe) @@ -179,7 +201,7 @@ public class InsertSharedRecordProcess implements BackendStep, MetaDataProducerI } catch(Exception e) { - throw (new QException("Error inserting shared record", e)); + throw (new QException("Error sharing " + assetTableLabel, e)); } } From bd947c9e162d30f1821ba8ab666968a97af484dd Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:30:34 -0500 Subject: [PATCH 50/58] CE-882 Add ShareableTableMetaData --- .../metadata/frontend/QFrontendTableMetaData.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java index 4df7b4af..ef93e024 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/frontend/QFrontendTableMetaData.java @@ -39,6 +39,7 @@ import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; 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.sharing.ShareableTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; @@ -75,6 +76,8 @@ public class QFrontendTableMetaData private boolean usesVariants; private String variantTableLabel; + private ShareableTableMetaData shareableTableMetaData; + ////////////////////////////////////////////////////////////////////////////////// // do not add setters. take values from the source-object in the constructor!! // ////////////////////////////////////////////////////////////////////////////////// @@ -104,6 +107,8 @@ public class QFrontendTableMetaData } this.sections = tableMetaData.getSections(); + + this.shareableTableMetaData = tableMetaData.getShareableTableMetaData(); } if(includeJoins) @@ -367,4 +372,14 @@ public class QFrontendTableMetaData return (this.variantTableLabel); } + + + /******************************************************************************* + ** Getter for shareableTableMetaData + ** + *******************************************************************************/ + public ShareableTableMetaData getShareableTableMetaData() + { + return shareableTableMetaData; + } } From 9ebf07665aeee071535225876447023729f4a9d1 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:30:56 -0500 Subject: [PATCH 51/58] CE-882 Add possible values outside of table or process --- .../javalin/QJavalinImplementation.java | 88 ++++++++++++++----- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java index 9fa28ea8..fc9bdda5 100644 --- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java @@ -109,6 +109,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; 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.frontend.QFrontendVariant; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.session.QSession; @@ -366,8 +367,8 @@ public class QJavalinImplementation post("/export", QJavalinImplementation::dataExportWithoutFilename); get("/export/{filename}", QJavalinImplementation::dataExportWithFilename); post("/export/{filename}", QJavalinImplementation::dataExportWithFilename); - get("/possibleValues/{fieldName}", QJavalinImplementation::possibleValues); - post("/possibleValues/{fieldName}", QJavalinImplementation::possibleValues); + get("/possibleValues/{fieldName}", QJavalinImplementation::possibleValuesForTableField); + post("/possibleValues/{fieldName}", QJavalinImplementation::possibleValuesForTableField); // todo - add put and/or patch at this level (without a primaryKey) to do a bulk update based on primaryKeys in the records. path("/{primaryKey}", () -> @@ -384,6 +385,9 @@ public class QJavalinImplementation }); }); + get("/possibleValues/{possibleValueSourceName}", QJavalinImplementation::possibleValuesStandalone); + post("/possibleValues/{possibleValueSourceName}", QJavalinImplementation::possibleValuesStandalone); + get("/widget/{name}", QJavalinImplementation::widget); // todo - can we just do a slow log here? get("/serverInfo", QJavalinImplementation::serverInfo); @@ -1695,9 +1699,9 @@ public class QJavalinImplementation /******************************************************************************* - ** + ** handler for a PVS that's associated with a field on a table. *******************************************************************************/ - private static void possibleValues(Context context) + private static void possibleValuesForTableField(Context context) { try { @@ -1736,36 +1740,71 @@ public class QJavalinImplementation /******************************************************************************* - ** + ** handler for a standalone (e.g., outside of a table or process) PVS. + *******************************************************************************/ + private static void possibleValuesStandalone(Context context) + { + try + { + String possibleValueSourceName = context.pathParam("possibleValueSourceName"); + + QPossibleValueSource pvs = qInstance.getPossibleValueSource(possibleValueSourceName); + if(pvs == null) + { + throw (new QNotFoundException("Could not find possible value source " + possibleValueSourceName + " in this instance.")); + } + + finishPossibleValuesRequest(context, possibleValueSourceName, null); + } + catch(Exception e) + { + handleException(context, e); + } + } + + + + /******************************************************************************* + ** continuation for table or process PVS's, *******************************************************************************/ static void finishPossibleValuesRequest(Context context, QFieldMetaData field) throws IOException, QException + { + QQueryFilter defaultQueryFilter = null; + if(field.getPossibleValueSourceFilter() != null) + { + Map values = new HashMap<>(); + if(context.formParamMap().containsKey("values")) + { + List valuesParamList = context.formParamMap().get("values"); + if(CollectionUtils.nullSafeHasContents(valuesParamList)) + { + String valuesParam = valuesParamList.get(0); + values = JsonUtils.toObject(valuesParam, Map.class); + } + } + + defaultQueryFilter = field.getPossibleValueSourceFilter().clone(); + defaultQueryFilter.interpretValues(values); + } + + finishPossibleValuesRequest(context, field.getPossibleValueSourceName(), defaultQueryFilter); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static void finishPossibleValuesRequest(Context context, String possibleValueSourceName, QQueryFilter defaultFilter) throws IOException, QException { String searchTerm = context.queryParam("searchTerm"); String ids = context.queryParam("ids"); - Map values = new HashMap<>(); - if(context.formParamMap().containsKey("values")) - { - List valuesParamList = context.formParamMap().get("values"); - if(CollectionUtils.nullSafeHasContents(valuesParamList)) - { - String valuesParam = valuesParamList.get(0); - values = JsonUtils.toObject(valuesParam, Map.class); - } - } - SearchPossibleValueSourceInput input = new SearchPossibleValueSourceInput(); setupSession(context, input); - input.setPossibleValueSourceName(field.getPossibleValueSourceName()); + input.setPossibleValueSourceName(possibleValueSourceName); input.setSearchTerm(searchTerm); - if(field.getPossibleValueSourceFilter() != null) - { - QQueryFilter filter = field.getPossibleValueSourceFilter().clone(); - filter.interpretValues(values); - input.setDefaultQueryFilter(filter); - } - if(StringUtils.hasContent(ids)) { List idList = new ArrayList<>(Arrays.asList(ids.split(","))); @@ -1777,6 +1816,7 @@ public class QJavalinImplementation Map result = new HashMap<>(); result.put("options", output.getResults()); context.result(JsonUtils.toJson(result)); + } From bab994edbe9b28d6f26e2666157c6e81d8970a61 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:32:15 -0500 Subject: [PATCH 52/58] CE-882 Add READ scope; fix filterForLockTree to make correct recursive call --- .../core/model/metadata/security/RecordSecurityLock.java | 5 +++-- .../model/metadata/security/RecordSecurityLockFilters.java | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java index 9902307a..6cf99e9e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLock.java @@ -129,8 +129,9 @@ public class RecordSecurityLock implements Cloneable *******************************************************************************/ public enum LockScope { - READ_AND_WRITE, - WRITE + READ_AND_WRITE, // lock both reads and writes + WRITE, // only lock writes + READ // only lock reads } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java index 4d5ad71b..c3eccc7e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/security/RecordSecurityLockFilters.java @@ -52,7 +52,7 @@ public class RecordSecurityLockFilters *******************************************************************************/ public static MultiRecordSecurityLock filterForReadLockTree(List recordSecurityLocks) { - return filterForLockTree(recordSecurityLocks, Set.of(RecordSecurityLock.LockScope.READ_AND_WRITE)); + return filterForLockTree(recordSecurityLocks, Set.of(RecordSecurityLock.LockScope.READ_AND_WRITE, RecordSecurityLock.LockScope.READ)); } @@ -88,7 +88,7 @@ public class RecordSecurityLockFilters { if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) { - MultiRecordSecurityLock filteredSubLock = filterForReadLockTree(multiRecordSecurityLock.getLocks()); + MultiRecordSecurityLock filteredSubLock = filterForLockTree(multiRecordSecurityLock.getLocks(), allowedScopes); filteredSubLock.setOperator(multiRecordSecurityLock.getOperator()); result.withLock(filteredSubLock); } From 7ecc005232875097c7f67773017ca2fdcbf5d538 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:32:25 -0500 Subject: [PATCH 53/58] CE-882 Re-label userId to Owner --- .../qqq/backend/core/model/savedreports/SavedReport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReport.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReport.java index 190efbd1..1fd531d1 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReport.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReport.java @@ -54,7 +54,7 @@ public class SavedReport extends QRecordEntity @QField(possibleValueSourceName = TablesPossibleValueSourceMetaDataProvider.NAME, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR, label = "Table", isRequired = true) private String tableName; - @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR, dynamicDefaultValueBehavior = DynamicDefaultValueBehavior.USER_ID) + @QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR, dynamicDefaultValueBehavior = DynamicDefaultValueBehavior.USER_ID, label = "Owner") private String userId; @QField(label = "Query Filter") From 5623c6f6c2f91f9071a50c7b04315df7b152d8e6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:32:36 -0500 Subject: [PATCH 54/58] CE-882 Add SHARED_SAVED_REPORT_JOIN_SAVED_REPORT --- .../SavedReportsMetaDataProvider.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java index caed5e6f..565c00e8 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java @@ -39,6 +39,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; @@ -60,6 +63,7 @@ public class SavedReportsMetaDataProvider { public static final String REPORT_STORAGE_TABLE_NAME = "reportStorage"; + public static final String SHARED_SAVED_REPORT_JOIN_SAVED_REPORT = "sharedSavedReportJoinSavedReport"; /******************************************************************************* @@ -89,6 +93,7 @@ public class SavedReportsMetaDataProvider // todo - param to enable sharing? // ///////////////////////////////////// instance.addTable(defineSharedSavedReportTable(recordTablesBackendName, backendDetailEnricher)); + instance.addJoin(defineSharedSavedReportJoinSavedReport()); if(instance.getPossibleValueSource(ShareScopePossibleValueMetaDataProducer.NAME) == null) { instance.addPossibleValueSource(new ShareScopePossibleValueMetaDataProducer().produce(new QInstance())); @@ -97,6 +102,22 @@ public class SavedReportsMetaDataProvider + /******************************************************************************* + ** + *******************************************************************************/ + private QJoinMetaData defineSharedSavedReportJoinSavedReport() + { + return (new QJoinMetaData() + .withName(SHARED_SAVED_REPORT_JOIN_SAVED_REPORT) + .withLeftTable(SharedSavedReport.TABLE_NAME) + .withRightTable(SavedReport.TABLE_NAME) + .withType(JoinType.MANY_TO_ONE) + .withJoinOn(new JoinOn("savedReportId", "id")) + ); + } + + + /******************************************************************************* ** *******************************************************************************/ From 2a6008f4dca698d10341f389281ca4018930837c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:32:44 -0500 Subject: [PATCH 55/58] CE-882 Add audiencePossibleValueSourceName --- .../sharing/ShareableTableMetaData.java | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java index a0193b63..76c2afb6 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java @@ -66,6 +66,11 @@ public class ShareableTableMetaData implements Serializable ///////////////////////////////////////////////// private String audienceTypesPossibleValueSourceName; + /////////////////////////////////////////////////// + // PVS that lists the available audience records // + /////////////////////////////////////////////////// + private String audiencePossibleValueSourceName; + ////////////////////////////////////////////////////////////// // name of a field in "this" table, that has the owner's id // ////////////////////////////////////////////////////////////// @@ -296,6 +301,37 @@ public class ShareableTableMetaData implements Serializable + /******************************************************************************* + ** Getter for audiencePossibleValueSourceName + *******************************************************************************/ + public String getAudiencePossibleValueSourceName() + { + return (this.audiencePossibleValueSourceName); + } + + + + /******************************************************************************* + ** Setter for audiencePossibleValueSourceName + *******************************************************************************/ + public void setAudiencePossibleValueSourceName(String audiencePossibleValueSourceName) + { + this.audiencePossibleValueSourceName = audiencePossibleValueSourceName; + } + + + + /******************************************************************************* + ** Fluent setter for audiencePossibleValueSourceName + *******************************************************************************/ + public ShareableTableMetaData withAudiencePossibleValueSourceName(String audiencePossibleValueSourceName) + { + this.audiencePossibleValueSourceName = audiencePossibleValueSourceName; + return (this); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -331,6 +367,8 @@ public class ShareableTableMetaData implements Serializable qInstanceValidator.assertCondition(sharedRecordTable.getFields().containsKey(audienceType.getFieldName()), prefix + "unrecognized fieldName [" + audienceType.getFieldName() + "] for shareableAudienceType [" + entry.getKey() + "] in sharedRecordTable [" + sharedRecordTableName + "]"); } + // todo - validate this audienceType.getSourceTableKeyFieldName() is a field, and it is a UKey + /* todo - make these optional i guess, because i didn't put user table in qqq boolean hasSourceTableKeyFieldName = qInstanceValidator.assertCondition(StringUtils.hasContent(audienceType.getSourceTableKeyFieldName()), prefix + "missing sourceTableKeyFieldName for shareableAudienceType [" + entry.getKey() + "]"); if(qInstanceValidator.assertCondition(qInstance.getTable(audienceType.getSourceTableName()) != null, prefix + "unrecognized sourceTableName [" + audienceType.getSourceTableName() + "] for shareableAudienceType [" + entry.getKey() + "] in sharedRecordTable [" + sharedRecordTableName + "]") && hasSourceTableKeyFieldName) @@ -351,6 +389,10 @@ public class ShareableTableMetaData implements Serializable { qInstanceValidator.assertCondition(qInstance.getPossibleValueSource(audienceTypesPossibleValueSourceName) != null, prefix + "unrecognized audienceTypesPossibleValueSourceName [" + audienceTypesPossibleValueSourceName + "]"); } - } + if(StringUtils.hasContent(audiencePossibleValueSourceName)) + { + qInstanceValidator.assertCondition(qInstance.getPossibleValueSource(audiencePossibleValueSourceName) != null, prefix + "unrecognized audiencePossibleValueSourceName [" + audiencePossibleValueSourceName + "]"); + } + } } From 13c64de2800e98a1ccd2eaf5c86e9edc4482c3f5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 28 Apr 2024 20:37:33 -0500 Subject: [PATCH 56/58] CE-882 Checkstyle? --- .../core/model/savedreports/SavedReportsMetaDataProvider.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java index 565c00e8..1c883782 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/savedreports/SavedReportsMetaDataProvider.java @@ -112,8 +112,7 @@ public class SavedReportsMetaDataProvider .withLeftTable(SharedSavedReport.TABLE_NAME) .withRightTable(SavedReport.TABLE_NAME) .withType(JoinType.MANY_TO_ONE) - .withJoinOn(new JoinOn("savedReportId", "id")) - ); + .withJoinOn(new JoinOn("savedReportId", "id"))); } From 7b2e9c4f5554fe2db78988b5323816f983f6330a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Apr 2024 07:50:45 -0500 Subject: [PATCH 57/58] CE-882 Fix test; more words in validation error --- .../qqq/backend/core/instances/QInstanceValidator.java | 2 +- .../backend/module/rdbms/sharing/SharingMetaDataProvider.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java index 7a9200b2..f371e86e 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java @@ -832,7 +832,7 @@ public class QInstanceValidator } } - assertCondition(Objects.equals(tmpTable.getName(), joinTableName), prefix + "has a joinNameChain doesn't end in the expected table [" + joinTableName + "]"); + assertCondition(Objects.equals(tmpTable.getName(), joinTableName), prefix + "has a joinNameChain doesn't end in the expected table [" + joinTableName + "] (was: " + tmpTable.getName() + ")"); assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); } diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java index 98f822c6..9dccf88d 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java @@ -93,11 +93,11 @@ public class SharingMetaDataProvider .withFieldName("userId")) .withLock(new RecordSecurityLock() .withSecurityKeyType(USER_ID_KEY_TYPE) - .withFieldName("sharedAsset.userId") + .withFieldName(SharedAsset.TABLE_NAME + ".userId") .withJoinNameChain(List.of(SHARED_ASSET_JOIN_ASSET))) .withLock(new RecordSecurityLock() .withSecurityKeyType(GROUP_ID_KEY_TYPE) - .withFieldName("sharedAsset.groupId") + .withFieldName(SharedAsset.TABLE_NAME + ".groupId") .withJoinNameChain(List.of(SHARED_ASSET_JOIN_ASSET))) )); QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Asset.TABLE_NAME)); From 2b6d1ea468437029ee15a62aca75cf3b901c11da Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Apr 2024 07:57:32 -0500 Subject: [PATCH 58/58] CE-882 Add testPossibleValueWithoutTableOrProcess --- .../javalin/QJavalinImplementationTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java index 9ae16b60..84382e57 100644 --- a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java +++ b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java @@ -844,6 +844,24 @@ class QJavalinImplementationTest extends QJavalinTestBase + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testPossibleValueWithoutTableOrProcess() + { + HttpResponse response = Unirest.get(BASE_URL + "/possibleValues/person").asString(); + assertEquals(200, response.getStatus()); + JSONObject jsonObject = JsonUtils.toJSONObject(response.getBody()); + assertNotNull(jsonObject); + assertNotNull(jsonObject.getJSONArray("options")); + assertEquals(6, jsonObject.getJSONArray("options").length()); + assertEquals(1, jsonObject.getJSONArray("options").getJSONObject(0).getInt("id")); + assertEquals("Darin Kelkhoff (1)", jsonObject.getJSONArray("options").getJSONObject(0).getString("label")); + } + + + /******************************************************************************* ** *******************************************************************************/