diff --git a/docs/actions/QueryAction.adoc b/docs/actions/QueryAction.adoc index 48ab2450..dfff3655 100644 --- a/docs/actions/QueryAction.adoc +++ b/docs/actions/QueryAction.adoc @@ -33,9 +33,6 @@ If the {link-table} has a `POST_QUERY_CUSTOMIZER` defined, then after records ar * `table` - *String, Required* - Name of the table being queried against. * `filter` - *<> object* - Specification for what records should be returned, based on *<>* objects, and how they should be sorted, based on *<>* objects. If a `filter` is not given, then all rows in the table will be returned by the query. -* `skip` - *Integer* - Optional number of records to be skipped at the beginning of the result set. -e.g., for implementing pagination. -* `limit` - *Integer* - Optional maximum number of records to be returned by the query. * `transaction` - *QBackendTransaction object* - Optional transaction object. ** Behavior for this object is backend-dependant. In an RDBMS backend, this object is generally needed if you want your query to see data that may have been modified within the same transaction. @@ -55,6 +52,14 @@ But if running a query to provide data as part of a process, then this can gener * `shouldMaskPassword` - *boolean, default: true* - Controls whether or not fields with `type` = `PASSWORD` should be masked, or if their actual values should be returned. * `queryJoins` - *List of <> objects* - Optional list of tables to be joined with the main table being queried. See QueryJoin below for further details. +* `fieldNamesToInclude` - *Set of String* - Optional set of field names to be included in the records. +** Fields from a queryJoin must be prefixed by the join table's name or alias, and a period. +Field names from the table being queried should not have any sort of prefix. +** A `null` set here (default) means to include all fields from the table and any queryJoins set as select=true. +** An empty set will cause an error, as well any unrecognized field names. +** `QueryAction` will validate the set of field names, and throw an exception if any unrecognized names are given. +** _Note that this is an optional feature, which some backend modules may not implement. +Meaning, they would always return all fields._ ==== QQueryFilter A key component of *<>*, a *QQueryFilter* defines both what records should be included in a query's results (e.g., an SQL `WHERE`), as well as how those results should be sorted (SQL `ORDER BY`). @@ -68,6 +73,9 @@ In general, multiple *orderBys* can be given (depending on backend implementatio ** Each *subFilter* can include its own additional *subFilters*. ** Each *subFilter* can specify a different *booleanOperator*. ** For example, consider the following *QQueryFilter*, that uses two *subFilters*, and a mix of *booleanOperators* +* `skip` - *Integer* - Optional number of records to be skipped at the beginning of the result set. +e.g., for implementing pagination. +* `limit` - *Integer* - Optional maximum number of records to be returned by the query. [source,java] ---- diff --git a/pom.xml b/pom.xml index 41a1556f..f2384d93 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,7 @@ - 0.21.0-SNAPSHOT + 0.22.0-SNAPSHOT UTF-8 UTF-8 diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/RecordCustomizerUtilityInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/RecordCustomizerUtilityInterface.java index afb54a71..fa91e9ab 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/RecordCustomizerUtilityInterface.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/customizers/RecordCustomizerUtilityInterface.java @@ -24,10 +24,12 @@ package com.kingsrook.qqq.backend.core.actions.customizers; import java.io.Serializable; 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.logging.QLogger; +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.tables.QTableMetaData; @@ -143,4 +145,19 @@ public interface RecordCustomizerUtilityInterface } } + + /******************************************************************************* + ** + *******************************************************************************/ + default Map getOldRecordMap(List oldRecordList, UpdateInput updateInput) + { + Map oldRecordMap = new HashMap<>(); + for(QRecord qRecord : oldRecordList) + { + oldRecordMap.put(qRecord.getValue(updateInput.getTable().getPrimaryKeyField()), qRecord); + } + + return (oldRecordMap); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java index a216e9e5..eff97f4a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/GenerateReportAction.java @@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction; import com.kingsrook.qqq.backend.core.actions.async.AsyncRecordPipeLoop; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.actions.reporting.customizers.DataSourceQueryInputCustomizer; +import com.kingsrook.qqq.backend.core.actions.reporting.customizers.ReportCustomRecordSourceInterface; import com.kingsrook.qqq.backend.core.actions.reporting.customizers.ReportViewCustomizer; import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; @@ -302,10 +303,19 @@ public class GenerateReportAction extends AbstractQActionFunction reportFormat.getMaxRows()) + CountInput countInput = new CountInput(); + countInput.setTableName(dataSource.getSourceTable()); + countInput.setFilter(queryFilter); + countInput.setQueryJoins(cloneDataSourceQueryJoins(dataSource)); + CountOutput countOutput = new CountAction().execute(countInput); + + count = countOutput.getCount(); + } + + if(count != null) + { + countByDataSource.put(dataSource.getName(), count); + + if(reportFormat.getMaxRows() != null && count > reportFormat.getMaxRows()) { throw (new QUserFacingException("The requested report would include more rows (" - + String.format("%,d", countOutput.getCount()) + ") than the maximum allowed (" + + String.format("%,d", count) + ") than the maximum allowed (" + String.format("%,d", reportFormat.getMaxRows()) + ") for the selected file format (" + reportFormat + ").")); } } @@ -423,13 +444,19 @@ public class GenerateReportAction extends AbstractQActionFunction QContext.getQInstance().getTable(dataSource.getSourceTable()).getLabel(), Objects.requireNonNullElse(dataSource.getSourceTable(), "")); AtomicInteger consumedCount = new AtomicInteger(0); - ///////////////////////////////////////////////////////////////// - // run a record pipe loop, over the query for this data source // - ///////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////////////////////// + // run a record pipe loop, over the query (or other data-supplier/source) for this data source // + ///////////////////////////////////////////////////////////////////////////////////////////////// RecordPipe recordPipe = new BufferedRecordPipe(1000); new AsyncRecordPipeLoop().run("Report[" + reportInput.getReportName() + "]", null, recordPipe, (callback) -> { - if(dataSource.getSourceTable() != null) + if(dataSource.getCustomRecordSource() != null) + { + ReportCustomRecordSourceInterface recordSource = QCodeLoader.getAdHoc(ReportCustomRecordSourceInterface.class, dataSource.getCustomRecordSource()); + recordSource.execute(reportInput, dataSource, recordPipe); + return (true); + } + else if(dataSource.getSourceTable() != null) { QQueryFilter queryFilter = dataSource.getQueryFilter() == null ? new QQueryFilter() : dataSource.getQueryFilter().clone(); setInputValuesInQueryFilter(reportInput, queryFilter); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/customizers/ReportCustomRecordSourceInterface.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/customizers/ReportCustomRecordSourceInterface.java new file mode 100644 index 00000000..522f3f88 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/customizers/ReportCustomRecordSourceInterface.java @@ -0,0 +1,43 @@ +/* + * 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.reporting.customizers; + + +import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportInput; +import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportDataSource; + + +/******************************************************************************* + ** Interface to be implemented to do a custom source of data for a report + ** (instead of just a query against a table). + *******************************************************************************/ +public interface ReportCustomRecordSourceInterface +{ + + /*************************************************************************** + ** Given the report input, put records into the pipe, for the report. + ***************************************************************************/ + void execute(ReportInput reportInput, QReportDataSource reportDataSource, RecordPipe recordPipe) throws QException; + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/excel/poi/ExcelPoiBasedStreamingExportStreamer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/excel/poi/ExcelPoiBasedStreamingExportStreamer.java index 1ce29911..ab175b86 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/excel/poi/ExcelPoiBasedStreamingExportStreamer.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/reporting/excel/poi/ExcelPoiBasedStreamingExportStreamer.java @@ -124,10 +124,11 @@ public class ExcelPoiBasedStreamingExportStreamer implements ExportStreamerInter private Writer activeSheetWriter = null; private StreamedSheetWriter sheetWriter = null; - private QReportView currentView = null; - private Map> fieldsPerView = new HashMap<>(); - private Map rowsPerView = new HashMap<>(); - private Map labelViewsByName = new HashMap<>(); + private QReportView currentView = null; + private Map> fieldsPerView = new HashMap<>(); + private Map rowsPerView = new HashMap<>(); + private Map labelViewsByName = new HashMap<>(); + private Map sheetReferenceByViewName = new HashMap<>(); @@ -180,6 +181,7 @@ public class ExcelPoiBasedStreamingExportStreamer implements ExportStreamerInter String sheetReference = sheet.getPackagePart().getPartName().getName().substring(1); sheetMapByExcelReference.put(sheetReference, sheet); sheetMapByViewName.put(view.getName(), sheet); + sheetReferenceByViewName.put(view.getName(), sheetReference); sheetCounter++; } @@ -446,7 +448,7 @@ public class ExcelPoiBasedStreamingExportStreamer implements ExportStreamerInter // - with a new output stream writer // // - and with a SpreadsheetWriter // ////////////////////////////////////////// - zipOutputStream.putNextEntry(new ZipEntry("xl/worksheets/sheet" + this.sheetIndex++ + ".xml")); + zipOutputStream.putNextEntry(new ZipEntry(sheetReferenceByViewName.get(view.getName()))); activeSheetWriter = new OutputStreamWriter(zipOutputStream); sheetWriter = new StreamedSheetWriter(activeSheetWriter); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java index 70b8810a..af60eeca 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/QueryAction.java @@ -26,6 +26,7 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -50,6 +51,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.QBackendMetaData; @@ -64,6 +66,7 @@ import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher; 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 com.kingsrook.qqq.backend.core.utils.ValueUtils; @@ -101,6 +104,8 @@ public class QueryAction throw (new QException("A table named [" + queryInput.getTableName() + "] was not found in the active QInstance")); } + validateFieldNamesToInclude(queryInput); + QBackendMetaData backend = queryInput.getBackend(); postQueryRecordCustomizer = QCodeLoader.getTableCustomizer(table, TableCustomizers.POST_QUERY_RECORD.getRole()); this.queryInput = queryInput; @@ -158,6 +163,109 @@ public class QueryAction + /*************************************************************************** + ** if QueryInput contains a set of FieldNamesToInclude, then validate that + ** those are known field names in the table being queried, or a selected + ** queryJoin. + ***************************************************************************/ + static void validateFieldNamesToInclude(QueryInput queryInput) throws QException + { + Set fieldNamesToInclude = queryInput.getFieldNamesToInclude(); + if(fieldNamesToInclude == null) + { + //////////////////////////////// + // null set means select all. // + //////////////////////////////// + return; + } + + if(fieldNamesToInclude.isEmpty()) + { + ///////////////////////////////////// + // empty set, however, is an error // + ///////////////////////////////////// + throw (new QException("An empty set of fieldNamesToInclude was given as queryInput, which is not allowed.")); + } + + List unrecognizedFieldNames = new ArrayList<>(); + Map selectedQueryJoins = null; + for(String fieldName : fieldNamesToInclude) + { + if(fieldName.contains(".")) + { + //////////////////////////////////////////////// + // handle names with dots - fields from joins // + //////////////////////////////////////////////// + String[] parts = fieldName.split("\\."); + if(parts.length != 2) + { + unrecognizedFieldNames.add(fieldName); + } + else + { + String tableOrAlias = parts[0]; + String fieldNamePart = parts[1]; + + //////////////////////////////////////////// + // build map of queryJoins being selected // + //////////////////////////////////////////// + if(selectedQueryJoins == null) + { + selectedQueryJoins = new HashMap<>(); + for(QueryJoin queryJoin : CollectionUtils.nonNullList(queryInput.getQueryJoins())) + { + if(queryJoin.getSelect()) + { + String joinTableOrAlias = queryJoin.getJoinTableOrItsAlias(); + QTableMetaData joinTable = QContext.getQInstance().getTable(queryJoin.getJoinTable()); + if(joinTable != null) + { + selectedQueryJoins.put(joinTableOrAlias, joinTable); + } + } + } + } + + if(!selectedQueryJoins.containsKey(tableOrAlias)) + { + /////////////////////////////////////////// + // unrecognized tableOrAlias is an error // + /////////////////////////////////////////// + unrecognizedFieldNames.add(fieldName); + } + else + { + QTableMetaData joinTable = selectedQueryJoins.get(tableOrAlias); + if(!joinTable.getFields().containsKey(fieldNamePart)) + { + ////////////////////////////////////////////////////////// + // unrecognized field within the join table is an error // + ////////////////////////////////////////////////////////// + unrecognizedFieldNames.add(fieldName); + } + } + } + } + else + { + /////////////////////////////////////////////////////////////////////// + // non-join fields - just ensure field name is in table's fields map // + /////////////////////////////////////////////////////////////////////// + if(!queryInput.getTable().getFields().containsKey(fieldName)) + { + unrecognizedFieldNames.add(fieldName); + } + } + } + + if(!unrecognizedFieldNames.isEmpty()) + { + throw (new QException("QueryInput contained " + unrecognizedFieldNames.size() + " unrecognized field name" + StringUtils.plural(unrecognizedFieldNames) + ": " + StringUtils.join(",", unrecognizedFieldNames))); + } + } + + + /******************************************************************************* ** shorthand way to call for the most common use-case, when you just want the ** records to be returned, and you just want to pass in a table name and filter. 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 311136ec..41f7ff2b 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 @@ -44,6 +44,7 @@ import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; import com.kingsrook.qqq.backend.core.actions.metadata.JoinGraph; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.reporting.customizers.ReportCustomRecordSourceInterface; import com.kingsrook.qqq.backend.core.actions.scripts.TestScriptActionInterface; import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider; import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; @@ -1660,9 +1661,12 @@ public class QInstanceValidator String dataSourceErrorPrefix = "Report " + reportName + " data source " + dataSource.getName() + " "; + boolean hasASource = false; + if(StringUtils.hasContent(dataSource.getSourceTable())) { - assertCondition(dataSource.getStaticDataSupplier() == null, dataSourceErrorPrefix + "has both a sourceTable and a staticDataSupplier (exactly 1 is required)."); + hasASource = true; + assertCondition(dataSource.getStaticDataSupplier() == null, dataSourceErrorPrefix + "has both a sourceTable and a staticDataSupplier (not compatible together)."); if(assertCondition(qInstance.getTable(dataSource.getSourceTable()) != null, dataSourceErrorPrefix + "source table " + dataSource.getSourceTable() + " is not a table in this instance.")) { if(dataSource.getQueryFilter() != null) @@ -1671,14 +1675,21 @@ public class QInstanceValidator } } } - else if(dataSource.getStaticDataSupplier() != null) + + if(dataSource.getStaticDataSupplier() != null) { + assertCondition(dataSource.getCustomRecordSource() == null, dataSourceErrorPrefix + "has both a staticDataSupplier and a customRecordSource (not compatible together)."); + hasASource = true; validateSimpleCodeReference(dataSourceErrorPrefix, dataSource.getStaticDataSupplier(), Supplier.class); } - else + + if(dataSource.getCustomRecordSource() != null) { - errors.add(dataSourceErrorPrefix + "does not have a sourceTable or a staticDataSupplier (exactly 1 is required)."); + hasASource = true; + validateSimpleCodeReference(dataSourceErrorPrefix, dataSource.getCustomRecordSource(), ReportCustomRecordSourceInterface.class); } + + assertCondition(hasASource, dataSourceErrorPrefix + "does not have a sourceTable, customRecordSource, or a staticDataSupplier."); } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryInput.java index 5a00b1a3..db4947f3 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/tables/query/QueryInput.java @@ -66,6 +66,14 @@ public class QueryInput extends AbstractTableActionInput implements QueryOrGetIn private List queryJoins = null; private boolean selectDistinct = false; + ///////////////////////////////////////////////////////////////////////////// + // if this set is null, then the default (all fields) should be included // + // if it's an empty set, that should throw an error // + // or if there are any fields in it that aren't valid fields on the table, // + // or in a selected queryJoin. // + ///////////////////////////////////////////////////////////////////////////// + private Set fieldNamesToInclude; + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if you say you want to includeAssociations, you can limit which ones by passing them in associationNamesToInclude. // // if you leave it null, you get all associations defined on the table. if you pass it as empty, you get none. // @@ -686,4 +694,35 @@ public class QueryInput extends AbstractTableActionInput implements QueryOrGetIn return (queryHints.contains(queryHint)); } + + + /******************************************************************************* + ** Getter for fieldNamesToInclude + *******************************************************************************/ + public Set getFieldNamesToInclude() + { + return (this.fieldNamesToInclude); + } + + + + /******************************************************************************* + ** Setter for fieldNamesToInclude + *******************************************************************************/ + public void setFieldNamesToInclude(Set fieldNamesToInclude) + { + this.fieldNamesToInclude = fieldNamesToInclude; + } + + + + /******************************************************************************* + ** Fluent setter for fieldNamesToInclude + *******************************************************************************/ + public QueryInput withFieldNamesToInclude(Set fieldNamesToInclude) + { + this.fieldNamesToInclude = fieldNamesToInclude; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/CompositeWidgetData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/CompositeWidgetData.java index 4cfbcb16..2cbae738 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/CompositeWidgetData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/CompositeWidgetData.java @@ -40,9 +40,10 @@ public class CompositeWidgetData extends AbstractBlockWidgetData> blocks = new ArrayList<>(); - private Map styleOverrides = new HashMap<>(); - - private Layout layout; + private Layout layout; + private Map styleOverrides = new HashMap<>(); + private String overlayHtml; + private Map overlayStyleOverrides = new HashMap<>(); @@ -218,4 +219,91 @@ public class CompositeWidgetData extends AbstractBlockWidgetData getOverlayStyleOverrides() + { + return (this.overlayStyleOverrides); + } + + + + /******************************************************************************* + ** Setter for overlayStyleOverrides + *******************************************************************************/ + public void setOverlayStyleOverrides(Map overlayStyleOverrides) + { + this.overlayStyleOverrides = overlayStyleOverrides; + } + + + + /******************************************************************************* + ** Fluent setter for overlayStyleOverrides + *******************************************************************************/ + public CompositeWidgetData withOverlayStyleOverrides(Map overlayStyleOverrides) + { + this.overlayStyleOverrides = overlayStyleOverrides; + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public CompositeWidgetData withOverlayStyleOverride(String key, Serializable value) + { + addOverlayStyleOverride(key, value); + return (this); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void addOverlayStyleOverride(String key, Serializable value) + { + if(this.overlayStyleOverrides == null) + { + this.overlayStyleOverrides = new HashMap<>(); + } + this.overlayStyleOverrides.put(key, value); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/AbstractBlockWidgetData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/AbstractBlockWidgetData.java index a1fdd99f..7e95f59f 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/AbstractBlockWidgetData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/AbstractBlockWidgetData.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks; import java.util.HashMap; import java.util.Map; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData; import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData; @@ -203,6 +204,19 @@ public abstract class AbstractBlockWidgetData< + /******************************************************************************* + ** Fluent setter for tooltip + ** + *******************************************************************************/ + @SuppressWarnings("unchecked") + public T withTooltip(CompositeWidgetData data) + { + this.tooltip = new BlockTooltip(data); + return (T) (this); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -398,6 +412,7 @@ public abstract class AbstractBlockWidgetData< } + /******************************************************************************* ** Getter for blockId *******************************************************************************/ @@ -428,5 +443,4 @@ public abstract class AbstractBlockWidgetData< return (T) this; } - } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/BlockTooltip.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/BlockTooltip.java index 2e17134c..4c12096d 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/BlockTooltip.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/dashboard/widgets/blocks/BlockTooltip.java @@ -22,14 +22,18 @@ package com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData; + + /******************************************************************************* ** A tooltip used within a (widget) block. ** *******************************************************************************/ public class BlockTooltip { - private String title; - private Placement placement = Placement.BOTTOM; + private CompositeWidgetData blockData; + private String title; + private Placement placement = Placement.BOTTOM; @@ -62,6 +66,17 @@ public class BlockTooltip + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public BlockTooltip(CompositeWidgetData blockData) + { + this.blockData = blockData; + } + + + /******************************************************************************* ** Getter for title *******************************************************************************/ @@ -122,4 +137,35 @@ public class BlockTooltip return (this); } + + + /******************************************************************************* + ** Getter for blockData + *******************************************************************************/ + public CompositeWidgetData getBlockData() + { + return (this.blockData); + } + + + + /******************************************************************************* + ** Setter for blockData + *******************************************************************************/ + public void setBlockData(CompositeWidgetData blockData) + { + this.blockData = blockData; + } + + + + /******************************************************************************* + ** Fluent setter for blockData + *******************************************************************************/ + public BlockTooltip withBlockData(CompositeWidgetData blockData) + { + this.blockData = blockData; + return (this); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/reporting/QReportDataSource.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/reporting/QReportDataSource.java index 2473e921..843c5241 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/reporting/QReportDataSource.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/reporting/QReportDataSource.java @@ -32,6 +32,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; /******************************************************************************* ** Meta-data definition of a source of data for a report (e.g., a table and query ** filter or custom-code reference). + ** + ** Runs in 3 modes: + ** + ** - If a customRecordSource is specified, then that code is executed to get the records. + ** - else, if a sourceTable is specified, then the corresponding queryFilter + ** (optionally along with queryJoins and queryInputCustomizer) is used. + ** - else a staticDataSupplier is used. *******************************************************************************/ public class QReportDataSource { @@ -44,6 +51,7 @@ public class QReportDataSource private QCodeReference queryInputCustomizer; private QCodeReference staticDataSupplier; + private QCodeReference customRecordSource; @@ -265,4 +273,35 @@ public class QReportDataSource return (this); } + + /******************************************************************************* + ** Getter for customRecordSource + *******************************************************************************/ + public QCodeReference getCustomRecordSource() + { + return (this.customRecordSource); + } + + + + /******************************************************************************* + ** Setter for customRecordSource + *******************************************************************************/ + public void setCustomRecordSource(QCodeReference customRecordSource) + { + this.customRecordSource = customRecordSource; + } + + + + /******************************************************************************* + ** Fluent setter for customRecordSource + *******************************************************************************/ + public QReportDataSource withCustomRecordSource(QCodeReference customRecordSource) + { + this.customRecordSource = customRecordSource; + return (this); + } + + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcess.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcess.java index 3eb738db..aa2413df 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcess.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcess.java @@ -46,7 +46,8 @@ public class BasicRunReportProcess public static final String STEP_NAME_EXECUTE = "execute"; public static final String STEP_NAME_ACCESS = "accessReport"; - public static final String FIELD_REPORT_NAME = "reportName"; + public static final String FIELD_REPORT_NAME = "reportName"; + public static final String FIELD_REPORT_FORMAT = "reportFormat"; diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/ExecuteReportStep.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/ExecuteReportStep.java index 57a87470..5e4b09af 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/ExecuteReportStep.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/ExecuteReportStep.java @@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.reporting.GenerateReportAction; 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.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportDestination; @@ -50,6 +51,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; public class ExecuteReportStep implements BackendStep { + /******************************************************************************* ** *******************************************************************************/ @@ -58,9 +60,10 @@ public class ExecuteReportStep implements BackendStep { try { - String reportName = runBackendStepInput.getValueString("reportName"); - QReportMetaData report = QContext.getQInstance().getReport(reportName); - File tmpFile = File.createTempFile(reportName, ".xlsx", new File("/tmp/")); + ReportFormat reportFormat = getReportFormat(runBackendStepInput); + String reportName = runBackendStepInput.getValueString("reportName"); + QReportMetaData report = QContext.getQInstance().getReport(reportName); + File tmpFile = File.createTempFile(reportName, "." + reportFormat.getExtension()); runBackendStepInput.getAsyncJobCallback().updateStatus("Generating Report"); @@ -69,7 +72,7 @@ public class ExecuteReportStep implements BackendStep ReportInput reportInput = new ReportInput(); reportInput.setReportName(reportName); reportInput.setReportDestination(new ReportDestination() - .withReportFormat(ReportFormat.XLSX) // todo - variable + .withReportFormat(reportFormat) .withReportOutputStream(reportOutputStream)); Map values = runBackendStepInput.getValues(); @@ -79,7 +82,7 @@ public class ExecuteReportStep implements BackendStep String downloadFileBaseName = getDownloadFileBaseName(runBackendStepInput, report); - runBackendStepOutput.addValue("downloadFileName", downloadFileBaseName + ".xlsx"); + runBackendStepOutput.addValue("downloadFileName", downloadFileBaseName + "." + reportFormat.getExtension()); runBackendStepOutput.addValue("serverFilePath", tmpFile.getCanonicalPath()); } } @@ -91,6 +94,22 @@ public class ExecuteReportStep implements BackendStep + /*************************************************************************** + ** + ***************************************************************************/ + private ReportFormat getReportFormat(RunBackendStepInput runBackendStepInput) throws QUserFacingException + { + String reportFormatInput = runBackendStepInput.getValueString(BasicRunReportProcess.FIELD_REPORT_FORMAT); + if(StringUtils.hasContent(reportFormatInput)) + { + return (ReportFormat.fromString(reportFormatInput)); + } + + return (ReportFormat.XLSX); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java index 1866385f..98b9572e 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/QueryActionTest.java @@ -24,6 +24,8 @@ package com.kingsrook.qqq.backend.core.actions.tables; import java.io.Serializable; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import com.kingsrook.qqq.backend.core.BaseTest; @@ -38,6 +40,7 @@ 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; @@ -54,6 +57,7 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.TestUtils; 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; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -541,4 +545,89 @@ class QueryActionTest extends BaseTest assertEquals(1, QueryAction.execute(TestUtils.TABLE_NAME_SHAPE, new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "SqUaRe"))).size()); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidateFieldNamesToInclude() throws QException + { + ///////////////////////////// + // cases that do not throw // + ///////////////////////////// + QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(null)); + + QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(Set.of("id"))); + + QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(Set.of("id", "firstName", "lastName", "birthDate", "modifyDate", "createDate"))); + + QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_SHAPE).withSelect(true)) + .withFieldNamesToInclude(Set.of("id", "firstName", "lastName", "birthDate", "modifyDate", "createDate"))); + + QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_SHAPE).withSelect(true)) + .withFieldNamesToInclude(Set.of("id", "firstName", TestUtils.TABLE_NAME_SHAPE + ".id", TestUtils.TABLE_NAME_SHAPE + ".noOfSides"))); + + QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_SHAPE).withSelect(true).withAlias("s")) + .withFieldNamesToInclude(Set.of("id", "s.id", "s.noOfSides"))); + + ////////////////////////// + // cases that do throw! // + ////////////////////////// + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(new HashSet<>()))) + .hasMessageContaining("empty set of fieldNamesToInclude"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(Set.of("notAField")))) + .hasMessageContaining("1 unrecognized field name: notAField"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(new LinkedHashSet<>(List.of("notAField", "alsoNot"))))) + .hasMessageContaining("2 unrecognized field names: notAField,alsoNot"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(new LinkedHashSet<>(List.of("notAField", "alsoNot", "join.not"))))) + .hasMessageContaining("3 unrecognized field names: notAField,alsoNot,join.not"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_SHAPE)) // oops, didn't select it! + .withFieldNamesToInclude(new LinkedHashSet<>(List.of("id", "firstName", TestUtils.TABLE_NAME_SHAPE + ".id", TestUtils.TABLE_NAME_SHAPE + ".noOfSides"))))) + .hasMessageContaining("2 unrecognized field names: shape.id,shape.noOfSides"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_SHAPE).withSelect(true)) + .withFieldNamesToInclude(Set.of(TestUtils.TABLE_NAME_SHAPE + ".noField")))) + .hasMessageContaining("1 unrecognized field name: shape.noField"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_SHAPE).withSelect(true).withAlias("s")) // oops, not using alias + .withFieldNamesToInclude(new LinkedHashSet<>(List.of("id", "firstName", TestUtils.TABLE_NAME_SHAPE + ".id", "s.noOfSides"))))) + .hasMessageContaining("1 unrecognized field name: shape.id"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_SHAPE).withSelect(true)) + .withFieldNamesToInclude(new LinkedHashSet<>(List.of("id", "firstName", TestUtils.TABLE_NAME_SHAPE + ".id", "noOfSides"))))) + .hasMessageContaining("1 unrecognized field name: noOfSides"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withQueryJoin(new QueryJoin("noJoinTable").withSelect(true)) + .withFieldNamesToInclude(Set.of("noJoinTable.id")))) + .hasMessageContaining("1 unrecognized field name: noJoinTable.id"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(Set.of("noJoin.id")))) + .hasMessageContaining("1 unrecognized field name: noJoin.id"); + + assertThatThrownBy(() -> QueryAction.validateFieldNamesToInclude(new QueryInput(TestUtils.TABLE_NAME_PERSON) + .withFieldNamesToInclude(Set.of("noDb.noJoin.id")))) + .hasMessageContaining("1 unrecognized field name: noDb.noJoin.id"); + } + } 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 68d1c8c2..e6651f3a 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 @@ -22,6 +22,7 @@ package com.kingsrook.qqq.backend.core.instances; +import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -38,6 +39,8 @@ import com.kingsrook.qqq.backend.core.actions.dashboard.PersonsByCreateDateBarCh import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ParentWidgetRenderer; import com.kingsrook.qqq.backend.core.actions.processes.CancelProcessActionTest; +import com.kingsrook.qqq.backend.core.actions.reporting.RecordPipe; +import com.kingsrook.qqq.backend.core.actions.reporting.customizers.ReportCustomRecordSourceInterface; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; @@ -45,6 +48,7 @@ import com.kingsrook.qqq.backend.core.instances.validation.plugins.AlwaysFailsPr import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +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; @@ -1712,7 +1716,7 @@ public class QInstanceValidatorTest extends BaseTest @Test void testReportDataSourceStaticDataSupplier() { - assertValidationFailureReasons((qInstance) -> qInstance.getReport(TestUtils.REPORT_NAME_SHAPES_PERSON).getDataSources().get(0).withStaticDataSupplier(new QCodeReference()), + assertValidationFailureReasons((qInstance) -> qInstance.getReport(TestUtils.REPORT_NAME_SHAPES_PERSON).getDataSources().get(0).withStaticDataSupplier(new QCodeReference(TestReportStaticDataSupplier.class)), "has both a sourceTable and a staticDataSupplier"); assertValidationFailureReasons((qInstance) -> @@ -1720,16 +1724,43 @@ public class QInstanceValidatorTest extends BaseTest QReportDataSource dataSource = qInstance.getReport(TestUtils.REPORT_NAME_SHAPES_PERSON).getDataSources().get(0); dataSource.setSourceTable(null); dataSource.setStaticDataSupplier(new QCodeReference(null, QCodeType.JAVA)); - }, - "missing a code reference name"); + }, "missing a code reference name"); assertValidationFailureReasons((qInstance) -> { QReportDataSource dataSource = qInstance.getReport(TestUtils.REPORT_NAME_SHAPES_PERSON).getDataSources().get(0); dataSource.setSourceTable(null); dataSource.setStaticDataSupplier(new QCodeReference(ArrayList.class)); - }, - "is not of the expected type"); + }, "is not of the expected type"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testReportDataSourceCustomRecordSource() + { + assertValidationFailureReasons((qInstance) -> qInstance.getReport(TestUtils.REPORT_NAME_SHAPES_PERSON).getDataSources().get(0) + .withSourceTable(null) + .withStaticDataSupplier(new QCodeReference(TestReportStaticDataSupplier.class)) + .withCustomRecordSource(new QCodeReference(TestReportCustomRecordSource.class)), + "has both a staticDataSupplier and a customRecordSource"); + + assertValidationFailureReasons((qInstance) -> + { + QReportDataSource dataSource = qInstance.getReport(TestUtils.REPORT_NAME_SHAPES_PERSON).getDataSources().get(0); + dataSource.setSourceTable(null); + dataSource.setCustomRecordSource(new QCodeReference(null, QCodeType.JAVA)); + }, "missing a code reference name"); + + assertValidationFailureReasons((qInstance) -> + { + QReportDataSource dataSource = qInstance.getReport(TestUtils.REPORT_NAME_SHAPES_PERSON).getDataSources().get(0); + dataSource.setSourceTable(null); + dataSource.setCustomRecordSource(new QCodeReference(ArrayList.class)); + }, "is not of the expected type"); } @@ -2371,5 +2402,38 @@ public class QInstanceValidatorTest extends BaseTest *******************************************************************************/ public static class ValidAuthCustomizer implements QAuthenticationModuleCustomizerInterface {} + + /*************************************************************************** + ** + ***************************************************************************/ + public static class TestReportStaticDataSupplier implements Supplier>> + { + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List> get() + { + return List.of(); + } + } + + + /*************************************************************************** + ** + ***************************************************************************/ + public static class TestReportCustomRecordSource implements ReportCustomRecordSourceInterface + { + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void execute(ReportInput reportInput, QReportDataSource reportDataSource, RecordPipe recordPipe) throws QException + { + + } + } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcessTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcessTest.java index d84e0a97..52ffd3f7 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcessTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/processes/implementations/reports/BasicRunReportProcessTest.java @@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.actions.reporting.GenerateReportActionTest import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData; @@ -43,6 +44,7 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class BasicRunReportProcessTest extends BaseTest { + /******************************************************************************* ** *******************************************************************************/ @@ -74,6 +76,18 @@ class BasicRunReportProcessTest extends BaseTest runProcessOutput = new RunProcessAction().execute(runProcessInput); assertThat(runProcessOutput.getProcessState().getNextStepName()).isPresent().get().isEqualTo(BasicRunReportProcess.STEP_NAME_ACCESS); assertThat(runProcessOutput.getValues()).containsKeys("downloadFileName", "serverFilePath"); + + /////////////////////////////////// + // assert we get xlsx by default // + /////////////////////////////////// + assertThat(runProcessOutput.getValueString("downloadFileName")).endsWith(".xlsx"); + + ///////////////////////////////////////////////////// + // re-run, requesting CSV, then assert we get that // + ///////////////////////////////////////////////////// + runProcessInput.addValue(BasicRunReportProcess.FIELD_REPORT_FORMAT, ReportFormat.CSV.name()); + runProcessOutput = new RunProcessAction().execute(runProcessInput); + assertThat(runProcessOutput.getValueString("downloadFileName")).endsWith(".csv"); } } \ 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 6738f5fc..8fd22d08 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 @@ -41,6 +41,7 @@ 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.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -176,18 +177,28 @@ public class AbstractMongoDBAction /******************************************************************************* ** Convert a mongodb document to a QRecord. *******************************************************************************/ - protected QRecord documentToRecord(QTableMetaData table, Document document) + protected QRecord documentToRecord(QueryInput queryInput, Document document) { - QRecord record = new QRecord(); + QTableMetaData table = queryInput.getTable(); + QRecord record = new QRecord(); + record.setTableName(table.getName()); + ///////////////////////////////////////////// + // build the set of field names to include // + ///////////////////////////////////////////// + Set fieldNamesToInclude = queryInput.getFieldNamesToInclude(); + List selectedFields = table.getFields().values() + .stream().filter(field -> fieldNamesToInclude == null || fieldNamesToInclude.contains(field.getName())) + .toList(); + ////////////////////////////////////////////////////////////////////////////////////////////// // first iterate over the table's fields, looking for them (at their backend name (path, // // if it has dots) inside the document note that we'll remove values from the document // // as we go - then after this loop, will handle all remaining values as unstructured fields // ////////////////////////////////////////////////////////////////////////////////////////////// Map values = record.getValues(); - for(QFieldMetaData field : table.getFields().values()) + for(QFieldMetaData field : selectedFields) { String fieldName = field.getName(); String fieldBackendName = getFieldBackendName(field); diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryAction.java index 6c4cdc30..dc5801ee 100644 --- a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryAction.java +++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBQueryAction.java @@ -41,6 +41,7 @@ import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMet import com.mongodb.client.FindIterable; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Projections; import org.bson.Document; import org.bson.conversions.Bson; @@ -96,6 +97,15 @@ public class MongoDBQueryAction extends AbstractMongoDBAction implements QueryIn //////////////////////////////////////////////////////////// FindIterable cursor = collection.find(mongoClientContainer.getMongoSession(), searchQuery); + /////////////////////////////////////////////////////////////////////////////////////////////// + // if input specifies a set of field names to include, then add a 'projection' to the cursor // + /////////////////////////////////////////////////////////////////////////////////////////////// + if(queryInput.getFieldNamesToInclude() != null) + { + List backendFieldNames = queryInput.getFieldNamesToInclude().stream().map(f -> getFieldBackendName(table.getField(f))).toList(); + cursor.projection(Projections.include(backendFieldNames)); + } + /////////////////////////////////// // add a sort operator if needed // /////////////////////////////////// @@ -138,7 +148,7 @@ public class MongoDBQueryAction extends AbstractMongoDBAction implements QueryIn actionTimeoutHelper.cancel(); setQueryStatFirstResultTime(); - QRecord record = documentToRecord(table, document); + QRecord record = documentToRecord(queryInput, document); queryOutput.addRecord(record); if(queryInput.getAsyncJobCallback().wasCancelRequested()) 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 3e51e7c2..7119ba98 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 @@ -29,6 +29,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; 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; @@ -54,6 +55,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -994,4 +997,46 @@ class MongoDBQueryActionTest extends BaseTest .allMatch(r -> r.getValueInteger("storeKey").equals(1)); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldNamesToInclude() throws QException + { + QQueryFilter filter = new QQueryFilter().withCriteria("firstName", QCriteriaOperator.EQUALS, "Darin"); + QueryInput queryInput = new QueryInput(TestUtils.TABLE_NAME_PERSON).withFilter(filter); + + QRecord record = new QueryAction().execute(queryInput.withFieldNamesToInclude(null)).getRecords().get(0); + assertTrue(record.getValues().containsKey("id")); + assertTrue(record.getValues().containsKey("firstName")); + assertTrue(record.getValues().containsKey("createDate")); + ////////////////////////////////////////////////////////////////////////////// + // note, we get an extra "metaData" field (??), which, i guess is expected? // + ////////////////////////////////////////////////////////////////////////////// + assertEquals(QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON).getFields().size() + 1, record.getValues().size()); + + record = new QueryAction().execute(queryInput.withFieldNamesToInclude(Set.of("id", "firstName"))).getRecords().get(0); + assertTrue(record.getValues().containsKey("id")); + assertTrue(record.getValues().containsKey("firstName")); + assertFalse(record.getValues().containsKey("createDate")); + assertEquals(2, record.getValues().size()); + ////////////////////////////////////////////////////////////////////////////////////////////// + // here, we'd have put _id (which mongo always returns) as "id", since caller requested it. // + ////////////////////////////////////////////////////////////////////////////////////////////// + assertFalse(record.getValues().containsKey("_id")); + + record = new QueryAction().execute(queryInput.withFieldNamesToInclude(Set.of("homeTown"))).getRecords().get(0); + assertFalse(record.getValues().containsKey("id")); + assertFalse(record.getValues().containsKey("firstName")); + assertFalse(record.getValues().containsKey("createDate")); + assertTrue(record.getValues().containsKey("homeTown")); + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // here, mongo always gives back _id (but, we won't have re-mapped it to "id", since caller didn't request it), so, do expect 2 fields here // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + assertEquals(2, record.getValues().size()); + assertTrue(record.getValues().containsKey("_id")); + } + } \ No newline at end of file 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 3e67ce31..8912f0aa 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 @@ -34,6 +34,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import com.kingsrook.qqq.backend.core.actions.interfaces.QueryInterface; @@ -94,7 +95,8 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf QTableMetaData table = queryInput.getTable(); String tableName = queryInput.getTableName(); - StringBuilder sql = new StringBuilder(makeSelectClause(queryInput)); + Selection selection = makeSelection(queryInput); + StringBuilder sql = new StringBuilder(selection.selectClause()); QQueryFilter filter = clonedOrNewFilter(queryInput.getFilter()); JoinsContext joinsContext = new JoinsContext(QContext.getQInstance(), tableName, queryInput.getQueryJoins(), filter); @@ -135,23 +137,6 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf needToCloseConnection = true; } - //////////////////////////////////////////////////////////////////////////// - // build the list of fields that will be processed in the result-set loop // - //////////////////////////////////////////////////////////////////////////// - List fieldList = new ArrayList<>(table.getFields().values().stream().toList()); - for(QueryJoin queryJoin : CollectionUtils.nonNullList(queryInput.getQueryJoins())) - { - if(queryJoin.getSelect()) - { - QTableMetaData joinTable = QContext.getQInstance().getTable(queryJoin.getJoinTable()); - String tableNameOrAlias = queryJoin.getJoinTableOrItsAlias(); - for(QFieldMetaData joinField : joinTable.getFields().values()) - { - fieldList.add(joinField.clone().withName(tableNameOrAlias + "." + joinField.getName())); - } - } - } - Long mark = System.currentTimeMillis(); try @@ -199,7 +184,7 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf for(int i = 1; i <= metaData.getColumnCount(); i++) { - QFieldMetaData field = fieldList.get(i - 1); + QFieldMetaData field = selection.fields().get(i - 1); if(!queryInput.getShouldFetchHeavyFields() && field.getIsHeavy()) { @@ -294,26 +279,62 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf + /*************************************************************************** + ** output wrapper for makeSelection method. + ** - selectClause is everything from SELECT up to (but not including) FROM + ** - fields are those being selected, in the same order, and with mutated + ** names for join fields. + ***************************************************************************/ + private record Selection(String selectClause, List fields) + { + + } + + + /******************************************************************************* - ** + ** For a given queryInput, determine what fields are being selected - returning + ** a record containing the SELECT clause, as well as a List of QFieldMetaData + ** representing those fields - where - note - the names for fields from join + ** tables will be prefixed by the join table nameOrAlias. *******************************************************************************/ - private String makeSelectClause(QueryInput queryInput) throws QException + private Selection makeSelection(QueryInput queryInput) throws QException { QInstance instance = QContext.getQInstance(); String tableName = queryInput.getTableName(); List queryJoins = queryInput.getQueryJoins(); QTableMetaData table = instance.getTable(tableName); - boolean requiresDistinct = queryInput.getSelectDistinct() || doesSelectClauseRequireDistinct(table); - String clausePrefix = (requiresDistinct) ? "SELECT DISTINCT " : "SELECT "; + Set fieldNamesToInclude = queryInput.getFieldNamesToInclude(); - List fieldList = new ArrayList<>(table.getFields().values()); + /////////////////////////////////////////////////////////////////////////////////////////////// + // start with the main table's fields, optionally filtered by the set of fieldNamesToInclude // + /////////////////////////////////////////////////////////////////////////////////////////////// + List fieldList = table.getFields().values() + .stream().filter(field -> fieldNamesToInclude == null || fieldNamesToInclude.contains(field.getName())) + .toList(); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // map those field names to columns, joined with ", ". // + // if a field is heavy, and heavy fields aren't being selected, then replace that field name with a LENGTH function // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// String columns = fieldList.stream() .map(field -> Pair.of(field, escapeIdentifier(tableName) + "." + escapeIdentifier(getColumnName(field)))) .map(pair -> wrapHeavyFieldsWithLengthFunctionIfNeeded(pair, queryInput.getShouldFetchHeavyFields())) .collect(Collectors.joining(", ")); - StringBuilder rs = new StringBuilder(clausePrefix).append(columns); + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + // figure out if distinct is being used. then start building the select clause with the table's columns // + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + boolean requiresDistinct = queryInput.getSelectDistinct() || doesSelectClauseRequireDistinct(table); + StringBuilder selectClause = new StringBuilder((requiresDistinct) ? "SELECT DISTINCT " : "SELECT ").append(columns); + List selectionFieldList = new ArrayList<>(fieldList); + + boolean needCommaBeforeJoinFields = !columns.isEmpty(); + + /////////////////////////////////// + // add any 'selected' queryJoins // + /////////////////////////////////// for(QueryJoin queryJoin : CollectionUtils.nonNullList(queryJoins)) { if(queryJoin.getSelect()) @@ -325,16 +346,41 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf throw new QException("Requested join table [" + queryJoin.getJoinTable() + "] is not a defined table."); } - List joinFieldList = new ArrayList<>(joinTable.getFields().values()); + /////////////////////////////////// + // filter by fieldNamesToInclude // + /////////////////////////////////// + List joinFieldList = joinTable.getFields().values() + .stream().filter(field -> fieldNamesToInclude == null || fieldNamesToInclude.contains(tableNameOrAlias + "." + field.getName())) + .toList(); + if(joinFieldList.isEmpty()) + { + continue; + } + + ///////////////////////////////////////////////////// + // map to columns, wrapping heavy fields as needed // + ///////////////////////////////////////////////////// String joinColumns = joinFieldList.stream() .map(field -> Pair.of(field, escapeIdentifier(tableNameOrAlias) + "." + escapeIdentifier(getColumnName(field)))) .map(pair -> wrapHeavyFieldsWithLengthFunctionIfNeeded(pair, queryInput.getShouldFetchHeavyFields())) .collect(Collectors.joining(", ")); - rs.append(", ").append(joinColumns); + + //////////////////////////////////////////////////////////////////////////////////////////////// + // append to output objects. // + // note that fields are cloned, since we are changing their names to have table/alias prefix. // + //////////////////////////////////////////////////////////////////////////////////////////////// + if(needCommaBeforeJoinFields) + { + selectClause.append(", "); + } + selectClause.append(joinColumns); + needCommaBeforeJoinFields = true; + + selectionFieldList.addAll(joinFieldList.stream().map(field -> field.clone().withName(tableNameOrAlias + "." + field.getName())).toList()); } } - return (rs.toString()); + return (new Selection(selectClause.toString(), selectionFieldList)); } diff --git a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java index 44dfbad0..ae659653 100644 --- a/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java +++ b/qqq-backend-module-rdbms/src/test/java/com/kingsrook/qqq/backend/module/rdbms/actions/RDBMSQueryActionJoinsTest.java @@ -25,6 +25,7 @@ package com.kingsrook.qqq.backend.module.rdbms.actions; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; @@ -1052,4 +1053,48 @@ public class RDBMSQueryActionJoinsTest extends RDBMSActionTest assertEquals(5, new QueryAction().execute(new QueryInput(TestUtils.TABLE_NAME_PERSON)).getRecords().size()); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldNamesToInclude() throws QException + { + QueryInput queryInput = initQueryRequest(); + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withSelect(true)); + queryInput.withFieldNamesToInclude(Set.of("firstName", TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")); + QueryOutput queryOutput = new QueryAction().execute(queryInput); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().containsKey("firstName")); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().containsKey(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().size() == 2); + + //////////////////////////////////////////////////////////////////////////////////////////////////// + // re-run w/ null fieldNamesToInclude -- and should still see those 2, and more (values size > 2) // + //////////////////////////////////////////////////////////////////////////////////////////////////// + queryInput.withFieldNamesToInclude(null); + queryOutput = new QueryAction().execute(queryInput); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().containsKey("firstName")); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().containsKey(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().size() > 2); + + //////////////////////////////////////////////////////////////////////////// + // regression from original build - where only join fields made sql error // + //////////////////////////////////////////////////////////////////////////// + queryInput.withFieldNamesToInclude(Set.of(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")); + queryOutput = new QueryAction().execute(queryInput); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().containsKey(TestUtils.TABLE_NAME_PERSONAL_ID_CARD + ".idNumber")); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().size() == 1); + + ////////////////////////////////////////////////////////////////////////////////////////// + // similar regression to above, if one of the join tables didn't have anything selected // + ////////////////////////////////////////////////////////////////////////////////////////// + queryInput.withQueryJoin(new QueryJoin(TestUtils.TABLE_NAME_PERSONAL_ID_CARD).withAlias("id2").withSelect(true)); + queryInput.withFieldNamesToInclude(Set.of("firstName", "id2.idNumber")); + queryOutput = new QueryAction().execute(queryInput); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().containsKey("firstName")); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().containsKey("id2.idNumber")); + assertThat(queryOutput.getRecords()).allMatch(r -> r.getValues().size() == 2); + } + } 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 fda4e03e..2b084bf0 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 @@ -28,6 +28,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Predicate; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; @@ -48,11 +49,12 @@ 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 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -166,7 +168,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(4, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").equals(email)), "Should NOT find expected email address"); + assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").equals(email)), "Should NOT find expected email address"); } @@ -199,7 +201,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest .withValues(List.of(1_000_000)))); queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(4, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().noneMatch(r -> Objects.equals(1_000_000, r.getValueInteger("annualSalary"))), "Should NOT find expected salary"); + assertTrue(queryOutput.getRecords().stream().noneMatch(r -> Objects.equals(1_000_000, r.getValueInteger("annualSalary"))), "Should NOT find expected salary"); } @@ -219,7 +221,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(2) || r.getValueInteger("id").equals(4)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(2) || r.getValueInteger("id").equals(4)), "Should find expected ids"); } @@ -239,7 +241,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(5)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(5)), "Should find expected ids"); } @@ -259,7 +261,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches("darin.*")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches("darin.*")), "Should find matching email address"); } @@ -279,7 +281,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); } @@ -299,7 +301,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); } @@ -319,7 +321,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(4, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); } @@ -339,7 +341,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches(".*gmail.com")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueString("email").matches(".*gmail.com")), "Should find matching email address"); } @@ -359,7 +361,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(4, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches("darin.*")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches("darin.*")), "Should find matching email address"); } @@ -379,7 +381,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(4, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches(".*kelkhoff.*")), "Should find matching email address"); } @@ -399,7 +401,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(4, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches(".*gmail.com")), "Should find matching email address"); + assertTrue(queryOutput.getRecords().stream().noneMatch(r -> r.getValueString("email").matches(".*gmail.com")), "Should find matching email address"); } @@ -419,7 +421,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(2)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(2)), "Should find expected ids"); } @@ -439,7 +441,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(2)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(2)), "Should find expected ids"); } @@ -459,7 +461,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(4) || r.getValueInteger("id").equals(5)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(4) || r.getValueInteger("id").equals(5)), "Should find expected ids"); } @@ -479,7 +481,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest ); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(4) || r.getValueInteger("id").equals(5)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(4) || r.getValueInteger("id").equals(5)), "Should find expected ids"); } @@ -498,7 +500,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest )); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValue("birthDate") == null), "Should find expected row"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValue("birthDate") == null), "Should find expected row"); } @@ -517,7 +519,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest )); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(5, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValue("firstName") != null), "Should find expected rows"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValue("firstName") != null), "Should find expected rows"); } @@ -537,7 +539,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest )); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(3, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(2) || r.getValueInteger("id").equals(3) || r.getValueInteger("id").equals(4)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(2) || r.getValueInteger("id").equals(3) || r.getValueInteger("id").equals(4)), "Should find expected ids"); } @@ -557,7 +559,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest )); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(5)), "Should find expected ids"); + assertTrue(queryOutput.getRecords().stream().allMatch(r -> r.getValueInteger("id").equals(1) || r.getValueInteger("id").equals(5)), "Should find expected ids"); } @@ -583,7 +585,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.LESS_THAN).withValues(List.of(new Now())))); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); + assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); } { @@ -593,7 +595,7 @@ public class RDBMSQueryActionTest extends RDBMSActionTest .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.LESS_THAN).withValues(List.of(NowWithOffset.plus(2, ChronoUnit.DAYS))))); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(1, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); + assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); } { @@ -603,8 +605,8 @@ public class RDBMSQueryActionTest extends RDBMSActionTest .withCriteria(new QFilterCriteria().withFieldName("birthDate").withOperator(QCriteriaOperator.GREATER_THAN).withValues(List.of(NowWithOffset.minus(5, ChronoUnit.DAYS))))); QueryOutput queryOutput = new RDBMSQueryAction().execute(queryInput); assertEquals(2, queryOutput.getRecords().size(), "Expected # of rows"); - Assertions.assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); - Assertions.assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("future")), "Should find expected row"); + assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("past")), "Should find expected row"); + assertTrue(queryOutput.getRecords().stream().anyMatch(r -> r.getValue("firstName").equals("future")), "Should find expected row"); } } @@ -1005,7 +1007,36 @@ public class RDBMSQueryActionTest extends RDBMSActionTest queryInput.setShouldFetchHeavyFields(true); records = new QueryAction().execute(queryInput).getRecords(); assertThat(records).describedAs("Some records should have the heavy homeTown field set when heavies are requested").anyMatch(r -> r.getValue("homeTown") != null); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testFieldNamesToInclude() throws QException + { + QQueryFilter filter = new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, 1); + QueryInput queryInput = new QueryInput(TestUtils.TABLE_NAME_PERSON).withFilter(filter); + + QRecord record = new QueryAction().execute(queryInput.withFieldNamesToInclude(null)).getRecords().get(0); + assertTrue(record.getValues().containsKey("id")); + assertTrue(record.getValues().containsKey("firstName")); + assertTrue(record.getValues().containsKey("createDate")); + assertEquals(QContext.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON).getFields().size(), record.getValues().size()); + + record = new QueryAction().execute(queryInput.withFieldNamesToInclude(Set.of("id", "firstName"))).getRecords().get(0); + assertTrue(record.getValues().containsKey("id")); + assertTrue(record.getValues().containsKey("firstName")); + assertFalse(record.getValues().containsKey("createDate")); + assertEquals(2, record.getValues().size()); + + record = new QueryAction().execute(queryInput.withFieldNamesToInclude(Set.of("homeTown"))).getRecords().get(0); + assertFalse(record.getValues().containsKey("id")); + assertFalse(record.getValues().containsKey("firstName")); + assertFalse(record.getValues().containsKey("createDate")); + assertEquals(1, record.getValues().size()); } } diff --git a/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION b/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION index 88541566..21574090 100644 --- a/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION +++ b/qqq-dev-tools/CURRENT-SNAPSHOT-VERSION @@ -1 +1 @@ -0.21.0 +0.22.0