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 fa026f92..6bfd0908 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 @@ -52,6 +52,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.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.fields.QFieldMetaData; @@ -68,6 +69,7 @@ import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface; 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; /******************************************************************************* @@ -101,6 +103,11 @@ public class InsertAction extends AbstractQActionFunction errors = insertOutput.getRecords().stream().flatMap(r -> r.getErrors().stream()).toList(); + if(CollectionUtils.nullSafeHasContents(errors)) + { + LOG.warn("Errors in insertAction", logPair("tableName", table.getName()), logPair("errorCount", errors.size()), errors.size() < 10 ? logPair("errors", errors) : logPair("first10Errors", errors.subList(0, 10))); + } manageAssociations(table, insertOutput.getRecords()); @@ -209,8 +216,9 @@ public class InsertAction extends AbstractQActionFunction inputRecordPage : CollectionUtils.getPages(insertInput.getRecords(), 500)) { @@ -219,10 +227,21 @@ public class InsertAction extends AbstractQActionFunction inputRecordJoinValues = new ArrayList<>(); QQueryFilter subFilter = new QQueryFilter(); - for(JoinOn joinOn : join.getJoinOns()) + for(JoinOn joinOn : rightMostJoin.getJoinOns()) { Serializable inputRecordValue = inputRecord.getValue(joinOn.getRightField()); inputRecordJoinValues.add(inputRecordValue); subFilter.addCriteria(inputRecordValue == null - ? new QFilterCriteria(joinOn.getLeftField(), QCriteriaOperator.IS_BLANK) - : new QFilterCriteria(joinOn.getLeftField(), QCriteriaOperator.EQUALS, inputRecordValue)); + ? new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.IS_BLANK) + : new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.EQUALS, inputRecordValue)); } if(!inputRecordMapByJoinFields.containsKey(inputRecordJoinValues)) @@ -265,11 +284,16 @@ public class InsertAction extends AbstractQActionFunction joinRecordValues = new ArrayList<>(); - for(JoinOn joinOn : join.getJoinOns()) + for(JoinOn joinOn : rightMostJoin.getJoinOns()) { - Serializable inputRecordValue = joinRecord.getValue(joinOn.getLeftField()); - joinRecordValues.add(inputRecordValue); + 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); } + joinRecordMapByJoinFields.put(joinRecordValues, joinRecord); } @@ -286,8 +310,12 @@ public class InsertAction extends AbstractQActionFunction n.contains("."))) + { + recordSecurityValue = joinRecord.getValue(recordSecurityLock.getFieldName()); + } for(QRecord inputRecord : inputRecords) { @@ -298,7 +326,7 @@ public class InsertAction extends AbstractQActionFunction joins = new ArrayList<>(); - for(String joinName : CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())) + if(fieldName.contains(".")) { - QJoinMetaData join = qInstance.getJoin(joinName); - if(join.getLeftTable().equals(table.getName())) + if(assertCondition(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " looks like a join (has a dot), but no joinNameChain was given.")) { - joins.add(new QueryJoin(join)); - } - else if(join.getRightTable().equals(table.getName())) - { - joins.add(new QueryJoin(join.flip())); + List joins = new ArrayList<>(); + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // ok - so - the join name chain is going to be like this: // + // for a table: orderLineItemExtrinsic (that's 2 away from order, where the security field is): // + // - securityFieldName = order.clientId // + // - joinNameChain = orderJoinOrderLineItem, orderLineItemJoinOrderLineItemExtrinsic // + // so - to navigate from the table to the security field, we need to reverse the joinNameChain, // + // and step (via tmpTable variable) back to the securityField // + /////////////////////////////////////////////////////////////////////////////////////////////////// + ArrayList joinNameChain = new ArrayList<>(CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())); + Collections.reverse(joinNameChain); + + QTableMetaData tmpTable = table; + + for(String joinName : joinNameChain) + { + QJoinMetaData join = qInstance.getJoin(joinName); + if(join == null) + { + errors.add(prefix + "joinNameChain contained an unrecognized join: " + joinName); + continue RECORD_SECURITY_LOCKS_LOOP; + } + + if(join.getLeftTable().equals(tmpTable.getName())) + { + joins.add(new QueryJoin(join)); + tmpTable = qInstance.getTable(join.getRightTable()); + } + else if(join.getRightTable().equals(tmpTable.getName())) + { + joins.add(new QueryJoin(join.flip())); + tmpTable = qInstance.getTable(join.getLeftTable()); + } + else + { + errors.add(prefix + "joinNameChain could not be followed through join: " + joinName); + continue RECORD_SECURITY_LOCKS_LOOP; + } + } + + assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); + } + } + else + { + if(assertCondition(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain()), prefix + "field name " + fieldName + " does not look like a join (does not have a dot), but a joinNameChain was given.")) + { + assertNoException(() -> table.getField(fieldName), prefix + "has an unrecognized fieldName: " + fieldName); } } - - assertCondition(findField(qInstance, table, joins, fieldName), prefix + "has an unrecognized fieldName: " + fieldName); } assertCondition(recordSecurityLock.getNullValueBehavior() != null, prefix + "is missing a nullValueBehavior"); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/ImplicitQueryJoinForSecurityLock.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/ImplicitQueryJoinForSecurityLock.java new file mode 100644 index 00000000..9d996727 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/ImplicitQueryJoinForSecurityLock.java @@ -0,0 +1,41 @@ +/* + * 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.model.actions.tables.query; + + +/******************************************************************************* + ** Specialization of a QueryJoin, for when the join is added to the query, + ** not by the caller, but by the framework, because it is implicitly needed + ** to provide a security lock. + *******************************************************************************/ +public class ImplicitQueryJoinForSecurityLock extends QueryJoin +{ + + /******************************************************************************* + ** package-private constructor - to make so outside users should not create + ** instances. + *******************************************************************************/ + ImplicitQueryJoinForSecurityLock() + { + } + +} 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 af829759..5a66e1e3 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,8 @@ package com.kingsrook.qqq.backend.core.model.actions.tables.query; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -75,25 +77,46 @@ public class JoinsContext /////////////////////////////////////////////////////////////// for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(instance.getTable(tableName).getRecordSecurityLocks())) { - for(String joinName : CollectionUtils.nonNullList(recordSecurityLock.getJoinNameChain())) + /////////////////////////////////////////////////////////////////////////////////////////////////// + // 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 = instance.getTable(mainTableName); + + for(String joinName : joinNameChain) { - if(this.queryJoins.stream().anyMatch(qj -> qj.getJoinMetaData().getName().equals(joinName))) + if(this.queryJoins.stream().anyMatch(queryJoin -> { - /////////////////////////////////////////////////////// - // we're good - we're already joining on this table! // - /////////////////////////////////////////////////////// + QJoinMetaData joinMetaData = Objects.requireNonNullElseGet(queryJoin.getJoinMetaData(), () -> findJoinMetaData(instance, tableName, queryJoin.getJoinTable())); + return (joinMetaData != null && Objects.equals(joinMetaData.getName(), joinName)); + })) + { + continue; + } + + QJoinMetaData join = instance.getJoin(joinName); + if(join.getLeftTable().equals(tmpTable.getName())) + { + QueryJoin queryJoin = new ImplicitQueryJoinForSecurityLock().withJoinMetaData(join).withType(QueryJoin.Type.INNER); + this.queryJoins.add(queryJoin); // todo something else with aliases? probably. + tmpTable = instance.getTable(join.getRightTable()); + } + else if(join.getRightTable().equals(tmpTable.getName())) + { + QueryJoin queryJoin = new ImplicitQueryJoinForSecurityLock().withJoinMetaData(join.flip()).withType(QueryJoin.Type.INNER); + this.queryJoins.add(queryJoin); // todo something else with aliases? probably. + tmpTable = instance.getTable(join.getLeftTable()); } else { - QJoinMetaData join = instance.getJoin(joinName); - if(tableName.equals(join.getRightTable())) - { - join = join.flip(); - } - - QueryJoin queryJoin = new QueryJoin().withJoinMetaData(join).withType(QueryJoin.Type.INNER); - this.queryJoins.add(queryJoin); // todo something else with aliases? probably. - processQueryJoin(queryJoin); + throw (new QException("Error adding security lock joins to query - table name [" + tmpTable.getName() + "] not found in join [" + joinName + "]")); } } } @@ -297,6 +320,69 @@ public class JoinsContext + /******************************************************************************* + ** + *******************************************************************************/ + public QJoinMetaData findJoinMetaData(QInstance instance, String baseTableName, String joinTableName) + { + List matches = new ArrayList<>(); + if(baseTableName != null) + { + /////////////////////////////////////////////////////////////////////////// + // if query specified a left-table, look for a join between left & right // + /////////////////////////////////////////////////////////////////////////// + for(QJoinMetaData join : instance.getJoins().values()) + { + if(join.getLeftTable().equals(baseTableName) && join.getRightTable().equals(joinTableName)) + { + matches.add(join); + } + + ////////////////////////////// + // look in both directions! // + ////////////////////////////// + if(join.getRightTable().equals(baseTableName) && join.getLeftTable().equals(joinTableName)) + { + matches.add(join.flip()); + } + } + } + else + { + ///////////////////////////////////////////////////////////////////////////////////// + // if query didn't specify a left-table, then look for any join to the right table // + ///////////////////////////////////////////////////////////////////////////////////// + for(QJoinMetaData join : instance.getJoins().values()) + { + if(join.getRightTable().equals(joinTableName) && this.hasTable(join.getLeftTable())) + { + matches.add(join); + } + + ////////////////////////////// + // look in both directions! // + ////////////////////////////// + if(join.getLeftTable().equals(joinTableName) && this.hasTable(join.getRightTable())) + { + matches.add(join.flip()); + } + } + } + + if(matches.size() == 1) + { + return (matches.get(0)); + } + else if(matches.size() > 1) + { + throw (new RuntimeException("More than 1 join was found between [" + baseTableName + "] and [" + joinTableName + "]. Specify which one in your QueryJoin.")); + } + + return (null); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/joins/QJoinMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/joins/QJoinMetaData.java index 70874938..21791bc4 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/joins/QJoinMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/joins/QJoinMetaData.java @@ -50,6 +50,7 @@ public class QJoinMetaData public QJoinMetaData flip() { return (new QJoinMetaData() + .withName(name) // does this need to be different?, e.g., + "Flipped"? .withLeftTable(rightTable) .withRightTable(leftTable) .withType(type.flip()) 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 8a66fae9..5ecbdf93 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 @@ -28,13 +28,18 @@ import java.util.List; /******************************************************************************* ** Define (for a table) a lock that applies to records in the table - e.g., ** a key type, and a field that has values for that key. - * + ** + ** Here's an example of how the joinNameChain should be set up: + ** given a table: orderLineItemExtrinsic (that's 2 away from order, where the security field is): + ** - recordSecurityLock.fieldName = order.clientId + ** - recordSecurityLock.joinNameChain = [orderJoinOrderLineItem, orderLineItemJoinOrderLineItemExtrinsic] + ** that is - what's the chain that takes us FROM the security fieldName TO the table with the lock. *******************************************************************************/ public class RecordSecurityLock { private String securityKeyType; private String fieldName; - private List joinNameChain; // todo - add validation in validator!! + private List joinNameChain; private NullValueBehavior nullValueBehavior = NullValueBehavior.DENY; 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 e35f1a48..d08cb943 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 @@ -24,19 +24,24 @@ package com.kingsrook.qqq.backend.core.modules.backend.implementations.memory; import java.io.Serializable; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; 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.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.modules.backend.implementations.utils.BackendQueryFilterUtils; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; @@ -134,10 +139,15 @@ public class MemoryRecordStore { incrementStatistic(input); - Map tableData = getTableData(input.getTable()); - List records = new ArrayList<>(); + Collection tableData = getTableData(input.getTable()).values(); + List records = new ArrayList<>(); - for(QRecord qRecord : tableData.values()) + if(CollectionUtils.nullSafeHasContents(input.getQueryJoins())) + { + tableData = buildJoinCrossProduct(input); + } + + for(QRecord qRecord : tableData) { boolean recordMatches = BackendQueryFilterUtils.doesRecordMatch(input.getFilter(), qRecord); @@ -155,6 +165,87 @@ public class MemoryRecordStore + /******************************************************************************* + ** + *******************************************************************************/ + private Collection buildJoinCrossProduct(QueryInput input) + { + List crossProduct = new ArrayList<>(); + QTableMetaData leftTable = input.getTable(); + for(QRecord record : getTableData(leftTable).values()) + { + QRecord productRecord = new QRecord(); + addRecordToProduct(productRecord, record, leftTable.getName()); + crossProduct.add(productRecord); + } + + for(QueryJoin queryJoin : input.getQueryJoins()) + { + QTableMetaData nextTable = QContext.getQInstance().getTable(queryJoin.getJoinTable()); + Collection nextTableRecords = getTableData(nextTable).values(); + + List nextLevelProduct = new ArrayList<>(); + for(QRecord productRecord : crossProduct) + { + boolean matchFound = false; + for(QRecord nextTableRecord : nextTableRecords) + { + if(joinMatches(productRecord, nextTableRecord, queryJoin)) + { + QRecord joinRecord = new QRecord(productRecord); + addRecordToProduct(joinRecord, nextTableRecord, queryJoin.getJoinTableOrItsAlias()); + nextLevelProduct.add(joinRecord); + matchFound = true; + } + } + + if(!matchFound) + { + // todo - Left & Right joins + } + } + + crossProduct = nextLevelProduct; + } + + return (crossProduct); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private boolean joinMatches(QRecord productRecord, QRecord nextTableRecord, QueryJoin queryJoin) + { + for(JoinOn joinOn : queryJoin.getJoinMetaData().getJoinOns()) + { + Serializable leftValue = productRecord.getValue(queryJoin.getBaseTableOrAlias() + "." + joinOn.getLeftField()); + Serializable rightValue = nextTableRecord.getValue(joinOn.getRightField()); + if(!Objects.equals(leftValue, rightValue)) + { + return (false); + } + } + + return (true); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void addRecordToProduct(QRecord productRecord, QRecord record, String tableNameOrAlias) + { + for(Map.Entry entry : record.getValues().entrySet()) + { + productRecord.withValue(tableNameOrAlias + "." + entry.getKey(), entry.getValue()); + } + } + + + /******************************************************************************* ** *******************************************************************************/ 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 a08aa458..bd0a04b3 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 @@ -27,6 +27,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; @@ -71,6 +72,21 @@ public class BackendQueryFilterUtils { String fieldName = criterion.getFieldName(); Serializable value = qRecord.getValue(fieldName); + if(value == null) + { + /////////////////////////////////////////////////////////////////////////////////////////////////// + // 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... // + /////////////////////////////////////////////////////////////////////////////////////////////////// + if(fieldName.contains(".")) + { + Map values = qRecord.getValues(); + if(values.keySet().stream().noneMatch(n -> n.contains("."))) + { + value = qRecord.getValue(fieldName.substring(fieldName.indexOf(".") + 1)); + } + } + } boolean criterionMatches = doesCriteriaMatch(criterion, fieldName, value); diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionTest.java index 1664ea60..06238a8c 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/InsertActionTest.java @@ -291,7 +291,130 @@ class InsertActionTest extends BaseTest ** *******************************************************************************/ @Test - void testInsertSecurityJoins() throws QException + void testInsertMultiLevelSecurityJoins() throws QException + { + QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 1); + + ////////////////////////////////////////////////////////////////////////////////////// + // null value in the foreign key to the join-table that provides the security value // + ////////////////////////////////////////////////////////////////////////////////////// + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + insertInput.setRecords(List.of(new QRecord().withValue("lineItemId", null).withValue("key", "kidsCanCallYou").withValue("value", "HoJu"))); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(0).getErrors().get(0)); + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // value in the foreign key to the join-table that provides the security value, but the referenced record isn't found // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + insertInput.setRecords(List.of(new QRecord().withValue("lineItemId", 1701).withValue("key", "kidsCanCallYou").withValue("value", "HoJu"))); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(0).getErrors().get(0)); + } + + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // insert an order and lineItem with storeId=2 - then, reset our session to only have storeId=1 in it - and try to insert an order-line referencing that order. // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(2)); + InsertInput insertOrderInput = new InsertInput(); + insertOrderInput.setTableName(TestUtils.TABLE_NAME_ORDER); + insertOrderInput.setRecords(List.of(new QRecord().withValue("id", 42).withValue("storeId", 2))); + InsertOutput insertOrderOutput = new InsertAction().execute(insertOrderInput); + assertEquals(42, insertOrderOutput.getRecords().get(0).getValueInteger("id")); + + InsertInput insertLineItemInput = new InsertInput(); + insertLineItemInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM); + insertLineItemInput.setRecords(List.of(new QRecord().withValue("id", 4200).withValue("orderId", 42).withValue("sku", "BASIC1").withValue("quantity", 24))); + InsertOutput insertLineItemOutput = new InsertAction().execute(insertLineItemInput); + assertEquals(4200, insertLineItemOutput.getRecords().get(0).getValueInteger("id")); + + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(1)); + InsertInput insertLineItemExtrinsicInput = new InsertInput(); + insertLineItemExtrinsicInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + insertLineItemExtrinsicInput.setRecords(List.of(new QRecord().withValue("lineItemId", 4200).withValue("key", "kidsCanCallYou").withValue("value", "HoJu"))); + InsertOutput insertLineItemExtrinsicOutput = new InsertAction().execute(insertLineItemExtrinsicInput); + assertEquals("You do not have permission to insert this record.", insertLineItemExtrinsicOutput.getRecords().get(0).getErrors().get(0)); + } + + { + QContext.getQSession().withSecurityKeyValues(new HashMap<>()); + QContext.getQSession().withSecurityKeyValues(TestUtils.SECURITY_KEY_TYPE_STORE, List.of(1)); + InsertInput insertOrderInput = new InsertInput(); + insertOrderInput.setTableName(TestUtils.TABLE_NAME_ORDER); + insertOrderInput.setRecords(List.of(new QRecord().withValue("id", 47).withValue("storeId", 1))); + InsertOutput insertOrderOutput = new InsertAction().execute(insertOrderInput); + assertEquals(47, insertOrderOutput.getRecords().get(0).getValueInteger("id")); + + InsertInput insertLineItemInput = new InsertInput(); + insertLineItemInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM); + insertLineItemInput.setRecords(List.of(new QRecord().withValue("id", 4700).withValue("orderId", 47).withValue("sku", "BASIC1").withValue("quantity", 74))); + InsertOutput insertLineItemOutput = new InsertAction().execute(insertLineItemInput); + assertEquals(4700, insertLineItemOutput.getRecords().get(0).getValueInteger("id")); + + /////////////////////////////////////////////////////// + // combine all the above, plus one record that works // + /////////////////////////////////////////////////////// + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + insertInput.setRecords(List.of( + new QRecord().withValue("lineItemId", null).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 1701).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 4200).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 4700).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu") + )); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(0).getErrors().get(0)); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(1).getErrors().get(0)); + assertEquals("You do not have permission to insert this record.", insertOutput.getRecords().get(2).getErrors().get(0)); + assertEquals(0, insertOutput.getRecords().get(3).getErrors().size()); + assertNotNull(insertOutput.getRecords().get(3).getValueInteger("id")); + } + + { + ///////////////////////////////////////////////////////////////////////////////// + // one more time, but with multiple input records referencing each foreign key // + ///////////////////////////////////////////////////////////////////////////////// + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + insertInput.setRecords(List.of( + new QRecord().withValue("lineItemId", null).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 1701).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 4200).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 4700).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", null).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 1701).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 4200).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu"), + new QRecord().withValue("lineItemId", 4700).withValue("key", "theKidsCanCallYou").withValue("value", "HoJu") + )); + InsertOutput insertOutput = new InsertAction().execute(insertInput); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(0).getErrors().get(0)); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(1).getErrors().get(0)); + assertEquals("You do not have permission to insert this record.", insertOutput.getRecords().get(2).getErrors().get(0)); + assertEquals(0, insertOutput.getRecords().get(3).getErrors().size()); + assertNotNull(insertOutput.getRecords().get(3).getValueInteger("id")); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(4).getErrors().get(0)); + assertEquals("You do not have permission to insert this record - the referenced Order was not found.", insertOutput.getRecords().get(5).getErrors().get(0)); + assertEquals("You do not have permission to insert this record.", insertOutput.getRecords().get(6).getErrors().get(0)); + assertEquals(0, insertOutput.getRecords().get(7).getErrors().size()); + assertNotNull(insertOutput.getRecords().get(7).getValueInteger("id")); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsertSingleLevelSecurityJoins() throws QException { QContext.getQSession().withSecurityKeyValue(TestUtils.SECURITY_KEY_TYPE_STORE, 1); 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 9a34908a..361343cf 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 @@ -1605,8 +1605,25 @@ class QInstanceValidatorTest extends BaseTest assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setFieldName("notAField")), "unrecognized field"); assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setNullValueBehavior(null)), "missing a nullValueBehavior"); - // todo - remove once implemented - assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setFieldName("join.field")), "does not yet support finding a field that looks like a join field"); + assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setFieldName("join.field")), "Table order recordSecurityLock (of key type store) field name join.field looks like a join (has a dot), but no joinNameChain was given"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRecordSecurityLockJoinChains() + { + Function lockExtractor = qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC).getRecordSecurityLocks().get(0); + + assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setJoinNameChain(null)), "looks like a join (has a dot), but no joinNameChain was given"); + 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).setJoinNameChain(List.of("notAJoin"))), "an unrecognized join"); + assertValidationFailureReasons((qInstance -> lockExtractor.apply(qInstance).setJoinNameChain(List.of("orderLineItem"))), "joinNameChain could not be followed through join"); } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java index dcfb9388..a64f65e8 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/TestUtils.java @@ -596,6 +596,10 @@ public class TestUtils .withName(TABLE_NAME_LINE_ITEM_EXTRINSIC) .withBackendName(MEMORY_BACKEND_NAME) .withPrimaryKeyField("id") + .withRecordSecurityLock(new RecordSecurityLock() + .withSecurityKeyType(SECURITY_KEY_TYPE_STORE) + .withFieldName("order.storeId") + .withJoinNameChain(List.of("orderLineItem", "lineItemLineItemExtrinsic"))) .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false)) .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)) 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 b88ad2c2..c82a048b 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 @@ -47,6 +47,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; @@ -218,7 +219,7 @@ public abstract class AbstractRDBMSAction implements QActionInterface String baseTableName = Objects.requireNonNullElse(joinsContext.resolveTableNameOrAliasToTableName(queryJoin.getBaseTableOrAlias()), tableName); QJoinMetaData joinMetaData = Objects.requireNonNullElseGet(queryJoin.getJoinMetaData(), () -> { - QJoinMetaData found = findJoinMetaData(instance, joinsContext, baseTableName, queryJoin.getJoinTable()); + QJoinMetaData found = joinsContext.findJoinMetaData(instance, baseTableName, queryJoin.getJoinTable()); if(found == null) { throw (new RuntimeException("Could not find a join between tables [" + baseTableName + "][" + queryJoin.getJoinTable() + "]")); @@ -263,69 +264,6 @@ public abstract class AbstractRDBMSAction implements QActionInterface - /******************************************************************************* - ** - *******************************************************************************/ - private QJoinMetaData findJoinMetaData(QInstance instance, JoinsContext joinsContext, String baseTableName, String joinTableName) - { - List matches = new ArrayList<>(); - if(baseTableName != null) - { - /////////////////////////////////////////////////////////////////////////// - // if query specified a left-table, look for a join between left & right // - /////////////////////////////////////////////////////////////////////////// - for(QJoinMetaData join : instance.getJoins().values()) - { - if(join.getLeftTable().equals(baseTableName) && join.getRightTable().equals(joinTableName)) - { - matches.add(join); - } - - ////////////////////////////// - // look in both directions! // - ////////////////////////////// - if(join.getRightTable().equals(baseTableName) && join.getLeftTable().equals(joinTableName)) - { - matches.add(join.flip()); - } - } - } - else - { - ///////////////////////////////////////////////////////////////////////////////////// - // if query didn't specify a left-table, then look for any join to the right table // - ///////////////////////////////////////////////////////////////////////////////////// - for(QJoinMetaData join : instance.getJoins().values()) - { - if(join.getRightTable().equals(joinTableName) && joinsContext.hasTable(join.getLeftTable())) - { - matches.add(join); - } - - ////////////////////////////// - // look in both directions! // - ////////////////////////////// - if(join.getLeftTable().equals(joinTableName) && joinsContext.hasTable(join.getRightTable())) - { - matches.add(join.flip()); - } - } - } - - if(matches.size() == 1) - { - return (matches.get(0)); - } - else if(matches.size() > 1) - { - throw (new RuntimeException("More than 1 join was found between [" + baseTableName + "] and [" + joinTableName + "]. Specify which one in your QueryJoin.")); - } - - return (null); - } - - - /******************************************************************************* ** method that sub-classes should call to make a full WHERE clause, including ** security clauses. @@ -403,6 +341,16 @@ public abstract class AbstractRDBMSAction implements QActionInterface 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 : CollectionUtils.nonNullList(joinTable.getRecordSecurityLocks())) { @@ -436,43 +384,10 @@ public abstract class AbstractRDBMSAction implements QActionInterface } } - String fieldName = tableNameOrAlias + "." + recordSecurityLock.getFieldName(); - String fieldNameWithoutTablePrefix = recordSecurityLock.getFieldName().replaceFirst(".*\\.", ""); - String fieldNameTablePrefix = recordSecurityLock.getFieldName().replaceFirst("\\..*", ""); + String fieldName = tableNameOrAlias + "." + recordSecurityLock.getFieldName(); if(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain())) { - for(String joinName : recordSecurityLock.getJoinNameChain()) - { - QJoinMetaData joinMetaData = instance.getJoin(joinName); - - /* - for(QueryJoin queryJoin : joinsContext.getQueryJoins()) - { - if(queryJoin.getJoinMetaData().getName().equals(joinName)) - { - joinMetaData = queryJoin.getJoinMetaData(); - break; - } - } - */ - - if(joinMetaData == null) - { - throw (new RuntimeException("Could not find joinMetaData for recordSecurityLock with joinChain member [" + joinName + "]")); - } - - if(fieldNameTablePrefix.equals(joinMetaData.getLeftTable())) - { - table = instance.getTable(joinMetaData.getLeftTable()); - } - else - { - table = instance.getTable(joinMetaData.getRightTable()); - } - - tableNameOrAlias = table.getName(); - fieldName = tableNameOrAlias + "." + fieldNameWithoutTablePrefix; - } + fieldName = recordSecurityLock.getFieldName(); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -481,7 +396,19 @@ public abstract class AbstractRDBMSAction implements QActionInterface QQueryFilter lockFilter = new QQueryFilter(); List lockCriteria = new ArrayList<>(); lockFilter.setCriteria(lockCriteria); - List securityKeyValues = session.getSecurityKeyValues(recordSecurityLock.getSecurityKeyType(), table.getField(fieldNameWithoutTablePrefix).getType()); + + 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)) { /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertAction.java b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertAction.java index c964f740..91465e49 100644 --- a/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertAction.java +++ b/qqq-backend-module-rdbms/src/main/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSInsertAction.java @@ -82,7 +82,7 @@ public class RDBMSInsertAction extends AbstractRDBMSAction implements InsertInte .toList(); String columns = insertableFields.stream() - .map(this::getColumnName) + .map(f -> "`" + getColumnName(f) + "`") .collect(Collectors.joining(", ")); String questionMarks = insertableFields.stream() .map(x -> "?") diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java index a44f795b..0dad47e0 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/TestUtils.java @@ -25,6 +25,11 @@ package com.kingsrook.qqq.backend.module.rdbms; import java.io.InputStream; import java.sql.Connection; import java.util.List; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.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.QAuthenticationType; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; @@ -38,6 +43,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleVal import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; 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.Association; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSActionTest; import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; @@ -61,6 +67,7 @@ public class TestUtils public static final String TABLE_NAME_ORDER = "order"; public static final String TABLE_NAME_ITEM = "item"; public static final String TABLE_NAME_ORDER_LINE = "orderLine"; + public static final String TABLE_NAME_LINE_ITEM_EXTRINSIC = "orderLineExtrinsic"; public static final String TABLE_NAME_WAREHOUSE = "warehouse"; public static final String TABLE_NAME_WAREHOUSE_STORE_INT = "warehouseStoreInt"; @@ -231,6 +238,7 @@ public class TestUtils qInstance.addTable(defineBaseTable(TABLE_NAME_ORDER, "order") .withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType(TABLE_NAME_STORE).withFieldName("storeId")) + .withAssociation(new Association().withName("orderLine").withAssociatedTableName(TABLE_NAME_ORDER_LINE).withJoinName("orderJoinOrderLine")) .withField(new QFieldMetaData("storeId", QFieldType.INTEGER).withBackendName("store_id").withPossibleValueSourceName(TABLE_NAME_STORE)) .withField(new QFieldMetaData("billToPersonId", QFieldType.INTEGER).withBackendName("bill_to_person_id").withPossibleValueSourceName(TABLE_NAME_PERSON)) .withField(new QFieldMetaData("shipToPersonId", QFieldType.INTEGER).withBackendName("ship_to_person_id").withPossibleValueSourceName(TABLE_NAME_PERSON)) @@ -243,13 +251,28 @@ public class TestUtils ); qInstance.addTable(defineBaseTable(TABLE_NAME_ORDER_LINE, "order_line") - .withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType(TABLE_NAME_STORE).withFieldName("storeId")) + .withRecordSecurityLock(new RecordSecurityLock() + .withSecurityKeyType(TABLE_NAME_STORE) + .withFieldName("order.storeId") + .withJoinNameChain(List.of("orderJoinOrderLine"))) + .withAssociation(new Association().withName("extrinsics").withAssociatedTableName(TABLE_NAME_LINE_ITEM_EXTRINSIC).withJoinName("orderLineJoinLineItemExtrinsic")) .withField(new QFieldMetaData("orderId", QFieldType.INTEGER).withBackendName("order_id")) .withField(new QFieldMetaData("sku", QFieldType.STRING)) .withField(new QFieldMetaData("storeId", QFieldType.INTEGER).withBackendName("store_id").withPossibleValueSourceName(TABLE_NAME_STORE)) .withField(new QFieldMetaData("quantity", QFieldType.INTEGER)) ); + qInstance.addTable(defineBaseTable(TABLE_NAME_LINE_ITEM_EXTRINSIC, "line_item_extrinsic") + .withRecordSecurityLock(new RecordSecurityLock() + .withSecurityKeyType(TABLE_NAME_STORE) + .withFieldName("order.storeId") + .withJoinNameChain(List.of("orderJoinOrderLine", "orderLineJoinLineItemExtrinsic"))) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("orderLineId", QFieldType.INTEGER).withBackendName("order_line_id")) + .withField(new QFieldMetaData("key", QFieldType.STRING)) + .withField(new QFieldMetaData("value", QFieldType.STRING)) + ); + qInstance.addTable(defineBaseTable(TABLE_NAME_WAREHOUSE_STORE_INT, "warehouse_store_int") .withField(new QFieldMetaData("warehouseId", QFieldType.INTEGER).withBackendName("warehouse_id")) .withField(new QFieldMetaData("storeId", QFieldType.INTEGER).withBackendName("store_id")) @@ -321,6 +344,14 @@ public class TestUtils .withJoinOn(new JoinOn("storeId", "storeId")) ); + qInstance.addJoin(new QJoinMetaData() + .withName("orderLineJoinLineItemExtrinsic") + .withLeftTable(TABLE_NAME_ORDER_LINE) + .withRightTable(TABLE_NAME_LINE_ITEM_EXTRINSIC) + .withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", "orderLineId")) + ); + qInstance.addPossibleValueSource(new QPossibleValueSource() .withName("store") .withType(QPossibleValueSourceType.TABLE) @@ -349,4 +380,16 @@ public class TestUtils .withField(new QFieldMetaData("id", QFieldType.INTEGER)); } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static List queryTable(String tableName) throws QException + { + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(tableName); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + return (queryOutput.getRecords()); + } } diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateActionTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateActionTest.java index 418ecab0..15165b24 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateActionTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSAggregateActionTest.java @@ -329,8 +329,7 @@ public class RDBMSAggregateActionTest extends RDBMSActionTest QContext.setQSession(new QSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1)); aggregateOutput = new RDBMSAggregateAction().execute(aggregateInput); aggregateResult = aggregateOutput.getResults().get(0); - // note - this would be 33, except for that one order line that has a contradictory store id... - Assertions.assertEquals(32, aggregateResult.getAggregateValue(sumOfQuantity)); + Assertions.assertEquals(33, aggregateResult.getAggregateValue(sumOfQuantity)); } 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 e2d1b9fe..f1bc1763 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 @@ -24,6 +24,9 @@ package com.kingsrook.qqq.backend.module.rdbms.actions; import java.util.Collections; import java.util.List; +import java.util.Objects; +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.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; @@ -34,6 +37,7 @@ 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; +import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -140,6 +144,50 @@ public class RDBMSInsertActionTest extends RDBMSActionTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsertAssociations() throws QException + { + QContext.getQSession().withSecurityKeyValue(TestUtils.TABLE_NAME_STORE, 1); + + int originalNoOfOrderLineExtrinsics = TestUtils.queryTable(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC).size(); + int originalNoOfOrderLines = TestUtils.queryTable(TestUtils.TABLE_NAME_ORDER_LINE).size(); + int originalNoOfOrders = TestUtils.queryTable(TestUtils.TABLE_NAME_ORDER).size(); + + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_ORDER); + 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("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("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"))) + )); + new InsertAction().execute(insertInput); + + List orders = TestUtils.queryTable(TestUtils.TABLE_NAME_ORDER); + assertEquals(originalNoOfOrders + 1, orders.size()); + assertTrue(orders.stream().anyMatch(r -> Objects.equals(r.getValue("billToPersonId"), 100) && Objects.equals(r.getValue("shipToPersonId"), 200))); + + List orderLines = TestUtils.queryTable(TestUtils.TABLE_NAME_ORDER_LINE); + assertEquals(originalNoOfOrderLines + 2, orderLines.size()); + assertTrue(orderLines.stream().anyMatch(r -> Objects.equals(r.getValue("sku"), "BASIC1") && Objects.equals(r.getValue("quantity"), 1))); + assertTrue(orderLines.stream().anyMatch(r -> Objects.equals(r.getValue("sku"), "BASIC2") && Objects.equals(r.getValue("quantity"), 2))); + + List lineItemExtrinsics = TestUtils.queryTable(TestUtils.TABLE_NAME_LINE_ITEM_EXTRINSIC); + assertEquals(originalNoOfOrderLineExtrinsics + 3, lineItemExtrinsics.size()); + assertTrue(lineItemExtrinsics.stream().anyMatch(r -> Objects.equals(r.getValue("key"), "LINE-EXT-1.1") && Objects.equals(r.getValue("value"), "LINE-VAL-1"))); + assertTrue(lineItemExtrinsics.stream().anyMatch(r -> Objects.equals(r.getValue("key"), "LINE-EXT-2.1") && Objects.equals(r.getValue("value"), "LINE-VAL-2"))); + assertTrue(lineItemExtrinsics.stream().anyMatch(r -> Objects.equals(r.getValue("key"), "LINE-EXT-2.2") && Objects.equals(r.getValue("value"), "LINE-VAL-3"))); + } + + + private void assertAnInsertedPersonRecord(String firstName, String lastName, Integer id) throws Exception { runTestSql("SELECT * FROM person WHERE last_name = '" + lastName + "'", (rs -> { diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/reporting/GenerateReportActionRDBMSTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/reporting/GenerateReportActionRDBMSTest.java index f24d955f..e49bc014 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/reporting/GenerateReportActionRDBMSTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/reporting/GenerateReportActionRDBMSTest.java @@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat; import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportInput; 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.QueryJoin; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -162,6 +163,7 @@ public class GenerateReportActionRDBMSTest extends RDBMSActionTest .withDataSource(new QReportDataSource() .withSourceTable(TestUtils.TABLE_NAME_ORDER_LINE) .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_ITEM).withAlias("i").withSelect(true)) + .withQueryFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id"))) ) .withView(new QReportView() .withType(ReportType.TABLE) @@ -179,13 +181,13 @@ public class GenerateReportActionRDBMSTest extends RDBMSActionTest assertEquals(""" "Line Item Id","Item SKU","Item Store Id","Item Store Name" "1","QM-1","1","Q-Mart" - "5","QM-1","1","Q-Mart" "2","QM-2","1","Q-Mart" "3","QM-3","1","Q-Mart" "4","QRU-1","2","QQQ 'R' Us" + "5","QM-1","1","Q-Mart" "6","QRU-1","2","QQQ 'R' Us" - "8","QRU-1","2","QQQ 'R' Us" "7","QRU-2","2","QQQ 'R' Us" + "8","QRU-1","2","QQQ 'R' Us" "9","QD-1","3","QDepot" "10","QD-1","3","QDepot" "11","QD-1","3","QDepot" diff --git a/qqq-backend-module-rdbms/src/test/resources/prime-test-database.sql b/qqq-backend-module-rdbms/src/test/resources/prime-test-database.sql index 06accda1..eb2abfe2 100644 --- a/qqq-backend-module-rdbms/src/test/resources/prime-test-database.sql +++ b/qqq-backend-module-rdbms/src/test/resources/prime-test-database.sql @@ -79,6 +79,7 @@ INSERT INTO carrier (id, name, company_code, service_level) VALUES (9, 'USPS Sup INSERT INTO carrier (id, name, company_code, service_level) VALUES (10, 'DHL International', 'DHL', 'I'); INSERT INTO carrier (id, name, company_code, service_level) VALUES (11, 'GSO', 'GSO', 'G'); +DROP TABLE IF EXISTS line_item_extrinsic; DROP TABLE IF EXISTS order_line; DROP TABLE IF EXISTS item; DROP TABLE IF EXISTS `order`; @@ -138,7 +139,7 @@ CREATE TABLE order_line id INT AUTO_INCREMENT PRIMARY KEY, order_id INT REFERENCES `order`, sku VARCHAR(80), - store_id INT REFERENCES store, -- todo - as a challenge, if this field wasn't here, so we had to join through order... + store_id INT REFERENCES store, quantity INT ); @@ -177,3 +178,12 @@ CREATE TABLE warehouse_store_int INSERT INTO warehouse_store_int (warehouse_id, store_id) VALUES (1, 1); INSERT INTO warehouse_store_int (warehouse_id, store_id) VALUES (1, 2); INSERT INTO warehouse_store_int (warehouse_id, store_id) VALUES (1, 3); + +CREATE TABLE line_item_extrinsic +( + id INT AUTO_INCREMENT PRIMARY KEY, + order_line_id INT REFERENCES order_line, + `key` VARCHAR(80), + `value` VARCHAR(80) +); +