diff --git a/pom.xml b/pom.xml index 79723366..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 xpath > /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 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/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 4d4d6bff..e14de682 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 @@ -190,6 +190,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(".")) @@ -198,27 +201,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 b3cb59a1..4964f221 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 int capacity = 1_000; + private int capacity = DEFAULT_CAPACITY; private ArrayBlockingQueue queue = new ArrayBlockingQueue<>(capacity); private boolean isTerminated = false; @@ -70,11 +72,13 @@ 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) { - this.capacity = overrideCapacity; - queue = new ArrayBlockingQueue<>(overrideCapacity); + this.capacity = Objects.requireNonNullElse(overrideCapacity, DEFAULT_CAPACITY); + queue = new ArrayBlockingQueue<>(this.capacity); } 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)); } } } 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/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 1f235999..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 @@ -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; @@ -42,13 +43,16 @@ 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; 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; @@ -81,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); } } } @@ -246,45 +363,78 @@ 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). + *******************************************************************************/ + @SuppressWarnings("checkstyle:Indentation") + 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) + return (locksOfType); + } + + + + /******************************************************************************* + ** 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()) { - QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); - if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) + if(childLock instanceof MultiRecordSecurityLock childMultiLock) { - LOG.trace("Session has " + securityKeyType.getAllAccessKeyName() + " - not checking this lock."); - } - else - { - locksToCheck.add(recordSecurityLock); + updateOperators(childMultiLock, operator); } } - - return (locksToCheck); } @@ -292,7 +442,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) { @@ -302,7 +452,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 @@ -314,15 +464,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/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/instances/QInstanceValidator.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceValidator.java index 85c7e307..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 @@ -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; @@ -624,6 +625,11 @@ public class QInstanceValidator supplementalTableMetaData.validate(qInstance, table, this); } + if(table.getShareableTableMetaData() != null) + { + table.getShareableTableMetaData().validate(qInstance, table, this); + } + runPlugins(QTableMetaData.class, table, qInstance); }); } @@ -711,7 +717,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,91 +724,129 @@ 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) + + /******************************************************************************* + ** + *******************************************************************************/ + 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)) { - if(fieldName.contains(".")) + 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.")) { - 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<>(); + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // 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) { - 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) { - 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; - } + errors.add(prefix + "joinNameChain contained an unrecognized 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); + 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(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); + } + } + 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"); } + + assertCondition(recordSecurityLock.getNullValueBehavior() != null, prefix + "is missing a nullValueBehavior"); } 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 ** 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..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 @@ -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,17 @@ 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.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; 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 +66,23 @@ public class JoinsContext private final String mainTableName; private final List queryJoins; + 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. // //////////////////////////////////////////////////////////////// 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; + private Level logLevelForFilter = Level.OFF; @@ -74,54 +92,225 @@ 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(); + this.securityFilterCursor = this.securityFilter; + + // 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); + logFilter("After ensureFilterIsRepresented:", securityFilter); + + /////////////////////////////////////////////////////////////////////////////////////// + // ensure that any record locks on the main table, which require a join, are present // + /////////////////////////////////////////////////////////////////////////////////////// + MultiRecordSecurityLock multiRecordSecurityLock = RecordSecurityLockFilters.filterForReadLockTree(CollectionUtils.nonNullList(instance.getTable(tableName).getRecordSecurityLocks())); + for(RecordSecurityLock lock : multiRecordSecurityLock.getLocks()) + { + ensureRecordSecurityLockIsRepresented(tableName, tableName, lock, null); + logFilter("After ensureRecordSecurityLockIsRepresented[fieldName=" + lock.getFieldName() + "]:", securityFilter); + } + + /////////////////////////////////////////////////////////////////////////////////// + // 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(); + logFilter("After fillInMissingJoinMetaData:", securityFilter); + /////////////////////////////////////////////////////////////// // 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); + logFilter("After ensureAllJoinRecordSecurityLocksAreRepresented:", securityFilter); - 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); + } + + filter.addSubFilter(securityFilter); + } + + + + /******************************************************************************* + ** 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 // + // 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; + } + 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()); + + MultiRecordSecurityLock multiRecordSecurityLock = RecordSecurityLockFilters.filterForReadLockTree(CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks())); + for(RecordSecurityLock lock : multiRecordSecurityLock.getLocks()) + { + 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. // + // 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<>(); + + //////////////////////////////////////////////////////////////////////////// + // 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; + } + /////////////////////////////////////////////////////////////////////////////////////////////////// - // 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 +318,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 +349,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 +390,233 @@ 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); + + 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()); } 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); + + 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()); } 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()); + 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 (as long as we're in an AND filter) // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(session.hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) + { + haveAllAccessKey = true; + + 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<>()); + } + + //////////////////////////////////////////////////////////////////////////////////////// + // 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; + } + } + } + + ///////////////////////////////////////////////////////////////////////////////////////// + // 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); + } + + if(haveAllAccessKey) + { + //////////////////////////////////////////////////////////////////////////////////////////// + // 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 + { + 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 - 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 + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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 // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + boolean doNotPutCriteriaInJoinOn = securityFilterCursor.getBooleanOperator() == QQueryFilter.BooleanOperator.OR; + if(sourceQueryJoin != null && !doNotPutCriteriaInJoinOn) + { + 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) + { + 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)); + } + */ + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + // If this filter isn't for a queryJoin, then just add it to the main list of security sub-filters // + ///////////////////////////////////////////////////////////////////////////////////////////////////// + this.securityFilterCursor.addSubFilter(lockFilter); } } @@ -197,9 +628,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 +639,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 +708,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 +718,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 +738,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 +749,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 +778,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 +814,7 @@ public class JoinsContext } while(addedJoin); + log("Done adding missing join meta data"); } @@ -370,12 +824,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 +849,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 +894,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 +905,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 +960,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 +979,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 +1027,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 +1058,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 +1110,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 +1157,79 @@ public class JoinsContext LOG.log(logLevel, message, null, logPairs); } + + /******************************************************************************* + ** + *******************************************************************************/ + 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 + ** 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); + + System.out.println(securityFilter); + + 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/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/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 bf14f05f..118aacbf 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 @@ -306,6 +306,11 @@ public class QFilterCriteria implements Serializable, Cloneable @Override public String toString() { + if(fieldName == null) + { + return (""); + } + StringBuilder rs = new StringBuilder(fieldName); try { 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 fffd3c9e..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 @@ -138,7 +138,7 @@ public class QQueryFilter implements Serializable, Cloneable /******************************************************************************* - ** + ** recursively look at both this filter, and any sub-filters it may have. *******************************************************************************/ public boolean hasAnyCriteria() { @@ -151,7 +151,7 @@ public class QQueryFilter implements Serializable, Cloneable { for(QQueryFilter subFilter : subFilters) { - if(subFilter.hasAnyCriteria()) + if(subFilter != null && subFilter.hasAnyCriteria()) { return (true); } @@ -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) { 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/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; + } } 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); + } + } 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); + } + +} 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..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 @@ -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; @@ -52,6 +53,28 @@ public class RecordSecurityLock + /******************************************************************************* + ** + *******************************************************************************/ + @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); + } + + + /******************************************************************************* ** Constructor ** @@ -106,8 +129,9 @@ public class RecordSecurityLock *******************************************************************************/ public enum LockScope { - READ_AND_WRITE, - WRITE + READ_AND_WRITE, // lock both reads and writes + WRITE, // only lock writes + READ // only lock reads } @@ -265,4 +289,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..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 @@ -23,6 +23,7 @@ package com.kingsrook.qqq.backend.core.model.metadata.security; import java.util.List; +import java.util.Set; /******************************************************************************* @@ -46,6 +47,65 @@ 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, RecordSecurityLock.LockScope.READ)); + } + + + + /******************************************************************************* + ** 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); + + for(RecordSecurityLock recordSecurityLock : recordSecurityLocks) + { + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) + { + MultiRecordSecurityLock filteredSubLock = filterForLockTree(multiRecordSecurityLock.getLocks(), allowedScopes); + filteredSubLock.setOperator(multiRecordSecurityLock.getOperator()); + result.withLock(filteredSubLock); + } + else + { + if(allowedScopes.contains(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/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..76c2afb6 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/sharing/ShareableTableMetaData.java @@ -0,0 +1,398 @@ +/* + * 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; + + /////////////////////////////////////////////////// + // PVS that lists the available audience records // + /////////////////////////////////////////////////// + private String audiencePossibleValueSourceName; + + ////////////////////////////////////////////////////////////// + // 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); + } + + + + /******************************************************************************* + ** 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); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + 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 - 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) + { + 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 + "]"); + } + + if(StringUtils.hasContent(audiencePossibleValueSourceName)) + { + qInstanceValidator.assertCondition(qInstance.getPossibleValueSource(audiencePossibleValueSourceName) != null, prefix + "unrecognized audiencePossibleValueSourceName [" + audiencePossibleValueSourceName + "]"); + } + } +} 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/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") 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 dd706df9..6726d73e 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 @@ -47,10 +47,14 @@ 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; +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; import com.kingsrook.qqq.backend.core.processes.implementations.savedreports.RunScheduledReportMetaDataProducer; @@ -62,7 +66,8 @@ public class SavedReportsMetaDataProvider { public static final String REPORT_STORAGE_TABLE_NAME = "reportStorage"; - public static final String SAVED_REPORT_JOIN_SCHEDULED_REPORT = "scheduledReportJoinSavedReport"; + public static final String SAVED_REPORT_JOIN_SCHEDULED_REPORT = "scheduledReportJoinSavedReport"; + public static final String SHARED_SAVED_REPORT_JOIN_SAVED_REPORT = "sharedSavedReportJoinSavedReport"; public static final String SCHEDULED_REPORT_VALUES_WIDGET = "scheduledReportValuesWidget"; public static final String RENDER_REPORT_PROCESS_VALUES_WIDGET = "renderReportProcessValuesWidget"; @@ -111,6 +116,16 @@ public class SavedReportsMetaDataProvider { instance.addPossibleValueSource(new TimeZonePossibleValueSourceMetaDataProvider().produce()); } + + ///////////////////////////////////// + // 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())); + } } @@ -173,6 +188,21 @@ 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"))); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -255,6 +285,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); + } + + +} 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); + } +} 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) { 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 7909963a..d7d395fc 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 @@ -177,6 +177,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) @@ -232,16 +240,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/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 86fa2191..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 @@ -78,14 +78,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]); } } } @@ -177,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; } @@ -203,12 +207,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); @@ -217,6 +222,7 @@ public class BackendQueryFilterUtils else { accumulatorValue |= newValue; + accumulator.set(accumulatorValue); if(accumulatorValue) { return (true); 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 22ab5f17..777d3f72 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()); @@ -139,6 +150,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); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -148,6 +198,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(); @@ -247,4 +298,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/StreamedETLPreviewStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/etl/streamedwithfrontend/StreamedETLPreviewStep.java index e24e617a..46383ca8 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 12f584e2..1b30c4e1 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,14 +77,16 @@ 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"); - AbstractExtractStep extractStep = getExtractStep(runBackendStepInput); - AbstractTransformStep transformStep = getTransformStep(runBackendStepInput); - ////////////////////////////////////////////////////////////////////// // let the transform step override the capacity for the record pipe // ////////////////////////////////////////////////////////////////////// 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..5bf42978 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/GetSharedRecordsProcess.java @@ -0,0 +1,249 @@ +/* + * 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.Comparator; +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.getValue(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); + } + } + } + + //////////////////////////// + // sort results by labels // + //////////////////////////// + resultList.sort(Comparator.comparing(r -> r.getValueString("audienceLabel"))); + + 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..c3ba8688 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/sharing/InsertSharedRecordProcess.java @@ -0,0 +1,208 @@ +/* + * 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.Map; +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.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; +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"); + + String assetTableLabel = tableName; + try + { + SharedRecordProcessUtils.AssetTableAndRecord assetTableAndRecord = SharedRecordProcessUtils.getAssetTableAndRecord(tableName, recordIdString); + + ShareableTableMetaData shareableTableMetaData = assetTableAndRecord.shareableTableMetaData(); + QRecord assetRecord = assetTableAndRecord.record(); + Serializable recordId = assetTableAndRecord.recordId(); + assetTableLabel = assetTableAndRecord.table().getLabel(); + + 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; + String audienceTableLabel = "audience"; + if(StringUtils.hasContent(shareableAudienceType.getSourceTableName())) + { + QTableMetaData audienceTable = QContext.getQInstance().getTable(shareableAudienceType.getSourceTableName()); + 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)); + } + } + + //////////////////////////////// + // 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 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 sharing " + assetTableLabel + ": " + errorMessage.getMessage())); + } + } + catch(QException qe) + { + throw (qe); + } + catch(Exception e) + { + throw (new QException("Error sharing " + assetTableLabel, 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/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")); + } + + + /******************************************************************************* ** *******************************************************************************/ 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 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..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 @@ -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; @@ -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; @@ -1902,6 +1903,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"); } @@ -2003,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"); } @@ -2112,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) @@ -2206,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/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 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 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-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 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..d00e1898 --- /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("shareId")); + 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 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 fb6ff602..b33249e1 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 @@ -157,4 +157,4 @@ class QScheduleManagerTest extends BaseTest .anyMatch(l -> l.getMessage().matches(".*Scheduled new job.*TABLE_AUTOMATIONS.scheduledJob:4.*")); } -} \ No newline at end of file +} 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 ac62386f..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; @@ -42,7 +43,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; @@ -50,9 +50,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; @@ -66,16 +64,15 @@ 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.NullValueBehaviorUtil; -import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +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; 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; +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; @@ -95,6 +92,9 @@ public abstract class AbstractRDBMSAction protected PreparedStatement statement; protected boolean isCancelled = false; + private static Memoization doesSelectClauseRequireDistinctMemoization = new Memoization() + .withTimeout(Duration.ofDays(365)); + /******************************************************************************* @@ -210,7 +210,7 @@ public abstract class AbstractRDBMSAction /******************************************************************************* ** *******************************************************************************/ - 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)); @@ -227,17 +227,9 @@ public abstract class AbstractRDBMSAction //////////////////////////////////////////////////////////// // 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()) { @@ -268,6 +260,17 @@ public abstract class AbstractRDBMSAction + " = " + escapeIdentifier(joinTableOrAlias) + "." + escapeIdentifier(getColumnName((rightTable.getField(joinOn.getRightField()))))); } + + if(CollectionUtils.nullSafeHasContents(queryJoin.getSecurityCriteria())) + { + 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)); } @@ -283,34 +286,66 @@ public abstract class AbstractRDBMSAction *******************************************************************************/ 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); @@ -319,208 +354,72 @@ public abstract class AbstractRDBMSAction /******************************************************************************* - ** 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); + 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 = makeWhereClauseWithoutSecurity(instance, table, joinsContext, subFilter, params); + String subClause = makeWhereClause(joinsContext, subFilter, params); if(StringUtils.hasContent(subClause)) { clauses.add("(" + subClause + ")"); } } + return (String.join(" " + filter.getBooleanOperator().toString() + " ", clauses)); } - /******************************************************************************* - ** 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); - } - - - /******************************************************************************* ** + ** @return optional sql where sub-clause, as in "x AND y" *******************************************************************************/ - 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(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) - { - 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(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) - { - 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 Optional getSqlWhereStringAndPopulateParamsListFromNonNestedFilter(JoinsContext joinsContext, List criteria, QQueryFilter.BooleanOperator booleanOperator, List params) throws IllegalArgumentException { 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()); @@ -530,25 +429,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()) { @@ -561,9 +457,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 "; @@ -571,9 +466,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()) { @@ -586,87 +480,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()) @@ -674,9 +555,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()) @@ -684,24 +564,28 @@ 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; } - default: + case TRUE -> { - throw new IllegalArgumentException("Unexpected operator: " + criterion.getOperator()); + clause = " 1 = 1 "; + expectedNoOfParams = 0; } + case FALSE -> + { + clause = " 0 = 1 "; + expectedNoOfParams = 0; + } + default -> throw new IllegalStateException("Unexpected operator: " + criterion.getOperator()); } if(expectedNoOfParams != null) @@ -756,7 +640,16 @@ public abstract class AbstractRDBMSAction params.addAll(values); } - return (String.join(" " + booleanOperator.toString() + " ", clauses)); + ////////////////////////////////////////////////////////////////////////////// + // 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 null here // + ////////////////////////////////////////////////////////////////////////////// + if(clauses.isEmpty()) + { + return (Optional.empty()); + } + + return (Optional.of(String.join(" " + booleanOperator.toString() + " ", clauses))); } @@ -965,6 +858,7 @@ public abstract class AbstractRDBMSAction } + /******************************************************************************* ** Make it easy (e.g., for tests) to turn on logging of SQL *******************************************************************************/ @@ -1051,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); } } } @@ -1144,4 +1065,20 @@ public abstract class AbstractRDBMSAction } } + + + /******************************************************************************* + ** 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 4e0d0fad..21a2052f 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 7e7dbd2c..86f119fb 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 @@ -93,13 +93,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..1b9671cc --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/core/actions/reporting/ExportActionWithinRDBMSTest.java @@ -0,0 +1,87 @@ +/* + * 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.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; +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.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); + + 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/actions/RDBMSInsertActionTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertActionTest.java index 6a3cb3a8..74d8804e 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..12993de3 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java @@ -0,0 +1,1032 @@ +/* + * 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.Map; +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.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; +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.setLogSQLReformat(true); + AbstractRDBMSAction.setLogSQLOutput("system.out"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void afterEach() + { + AbstractRDBMSAction.setLogSQL(false); + AbstractRDBMSAction.setLogSQLReformat(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().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, null)); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(Collections.emptyMap())); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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(Collections.emptyMap())); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList()))); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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(Collections.emptyMap())); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + ////////////////////////////////////////////////////// + // with empty-list security key value, 0 rows found // + ////////////////////////////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList()))); + assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); + + //////////////////////////////// + // with 2 values, find 2 rows // + //////////////////////////////// + QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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)); + } + + + + /******************************************************************************* + ** 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()); + } + +} 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 223fa5f4..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 @@ -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; @@ -107,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"); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -783,675 +803,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().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, null)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList()))); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, null)); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValues(Map.of(TestUtils.TABLE_NAME_STORE, Collections.emptyList()))); - assertThat(new QueryAction().execute(queryInput).getRecords()).isEmpty(); - - QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -1627,68 +978,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().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1).withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 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); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -1718,51 +1007,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()); - } - - } - } 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..9dccf88d --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingMetaDataProvider.java @@ -0,0 +1,168 @@ +/* + * 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.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.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; + + +/******************************************************************************* + ** + *******************************************************************************/ +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"; + private static final String SHARED_ASSET_JOIN_ASSET = "sharedAssetJoinAsset"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + 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.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) + + //////////////////////////////////////// + // 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.TABLE_NAME + ".userId") + .withJoinNameChain(List.of(SHARED_ASSET_JOIN_ASSET))) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(GROUP_ID_KEY_TYPE) + .withFieldName(SharedAsset.TABLE_NAME + ".groupId") + .withJoinNameChain(List.of(SHARED_ASSET_JOIN_ASSET))) + )); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(Asset.TABLE_NAME)); + + qInstance.addTable(new QTableMetaData() + .withName(SharedAsset.TABLE_NAME) + .withBackendDetails(new RDBMSTableBackendDetails().withTableName("shared_asset")) + .withPrimaryKeyField("id") + .withBackendName(TestUtils.DEFAULT_BACKEND_NAME) + .withFieldsFromEntity(SharedAsset.class) + .withRecordSecurityLock(new MultiRecordSecurityLock() + .withOperator(MultiRecordSecurityLock.BooleanOperator.OR) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(USER_ID_KEY_TYPE) + .withFieldName("userId")) + .withLock(new RecordSecurityLock() + .withSecurityKeyType(GROUP_ID_KEY_TYPE) + .withFieldName("groupId")) + )); + QInstanceEnricher.setInferredFieldBackendNames(qInstance.getTable(SharedAsset.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.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")) + ); + + 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")) + ); + } + +} 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..c8b7fa20 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/SharingTest.java @@ -0,0 +1,514 @@ +/* + * 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.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.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; +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; + + +/******************************************************************************* + ** + *******************************************************************************/ +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; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @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(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(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(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 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(SharedAsset.TABLE_NAME).withRecordEntities(sharedAssetList)); + + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQueryAssetWithUserIdOnlySecurityKey() 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(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(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(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 testQuerySharedAssetDirectly() 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 testQueryAssetsWithLockThroughSharing() 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 testQueryAllAccessKeys() 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()); + + ////////////////////////////////////////////////////////////////////////////////////////////// + // 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(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @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(); + } + } + +} 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/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/SharedAsset.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/SharedAsset.java new file mode 100644 index 00000000..f0636525 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/sharing/model/SharedAsset.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 SharedAsset table + *******************************************************************************/ +public class SharedAsset extends QRecordEntity +{ + public static final String TABLE_NAME = "SharedAsset"; + + @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 = User.TABLE_NAME) + private Integer userId; + + @QField(possibleValueSourceName = Group.TABLE_NAME) + private Integer groupId; + + + /******************************************************************************* + ** Default constructor + *******************************************************************************/ + public SharedAsset() + { + } + + + + /******************************************************************************* + ** Constructor that takes a QRecord + *******************************************************************************/ + public SharedAsset(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 SharedAsset 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 SharedAsset 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 SharedAsset 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 SharedAsset withAssetId(Integer assetId) + { + 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 groupId + *******************************************************************************/ + public Integer getGroupId() + { + return (this.groupId); + } + + + + /******************************************************************************* + ** Setter for groupId + *******************************************************************************/ + public void setGroupId(Integer groupId) + { + this.groupId = groupId; + } + + + + /******************************************************************************* + ** Fluent setter for groupId + *******************************************************************************/ + public SharedAsset withGroupId(Integer groupId) + { + this.groupId = groupId; + 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..70a00914 --- /dev/null +++ b/qqq-backend-module-rdbms/src/test/resources/prime-test-database-sharing-test.sql @@ -0,0 +1,74 @@ +-- +-- 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 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 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, + user_id INTEGER, + group_id INTEGER +); + 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)); + } 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")); + } + + + /******************************************************************************* ** *******************************************************************************/ 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