diff --git a/api/src/org/labkey/api/action/ApiQueryResponse.java b/api/src/org/labkey/api/action/ApiQueryResponse.java index bbcaba1b7b9..66fc4d6af26 100644 --- a/api/src/org/labkey/api/action/ApiQueryResponse.java +++ b/api/src/org/labkey/api/action/ApiQueryResponse.java @@ -75,6 +75,7 @@ public class ApiQueryResponse implements ApiResponse private RenderContext _ctx = null; private boolean _schemaEditable = false; private boolean _metaDataOnly; + private boolean _countOnly = false; private DataRegion _dataRegion; private TableInfo _tinfo = null; private List _displayColumns = null; @@ -147,6 +148,16 @@ public void render(ApiResponseWriter writer) throws Exception if (_metaDataOnlyIncludesEmptyRowset) writeEmptyRowset(writer); } + else if (_countOnly) + { + loadTotalRowsOnly(); + if (_dataRegion.getTotalRows() != null) + _rowCount = _dataRegion.getTotalRows(); + + if (_includeMetaData) + writeMetaData(writer); + writeEmptyRowset(writer); + } else { // First run the query, so on potential SQLException we only serialize the exception instead of outputting all the metadata before the exception @@ -170,8 +181,11 @@ public void render(ApiResponseWriter writer) throws Exception _rowCount = _dataRegion.getTotalRows(); } } + } - long rowCount = _rowCount > 0 ? _rowCount : _offset + _numRespRows; + if (!_metaDataOnly) + { + long rowCount = _rowCount > 0 || _countOnly ? _rowCount : _offset + _numRespRows; writer.writeProperty("rowCount", rowCount); if (_includeMetaData) @@ -268,9 +282,7 @@ protected void writeMetaData(ApiResponseWriter writer) throws Exception protected Results getResults() throws Exception { - // If we're going to be writing JSON back, which is tolerant of extra spaces, allow async so we - // can monitor if the client has stopped listening. XML doesn't take kindly to leading spaces - _dataRegion.setAllowAsync(ApiResponseWriter.getResponseFormat(_viewContext.getRequest(), ApiResponseWriter.Format.JSON).isJson()); + _dataRegion.setAllowAsync(isAllowAsync()); try { return _dataRegion.getResults(_ctx); @@ -281,6 +293,26 @@ protected Results getResults() throws Exception } } + protected void loadTotalRowsOnly() + { + _dataRegion.setAllowAsync(isAllowAsync()); + try + { + _dataRegion.loadTotalRowsOnly(_ctx); + } + catch (ConversionException e) + { + throw new ApiUsageException(e); + } + } + + // If we're going to be writing JSON back, which is tolerant of extra spaces, allow async so we + // can monitor if the client has stopped listening. XML doesn't take kindly to leading spaces + private boolean isAllowAsync() + { + return ApiResponseWriter.getResponseFormat(_viewContext.getRequest(), ApiResponseWriter.Format.JSON).isJson(); + } + public List getFieldKeys() { return _fieldKeys; @@ -328,6 +360,12 @@ public void includeStyle(boolean withStyle) _doItWithStyle = withStyle; } + /** Return only rowCount (and metadata, if requested) via a COUNT(*) query, skipping the rows query. */ + public void countOnly(boolean countOnly) + { + _countOnly = countOnly; + } + protected double getFormatVersion() { return 8.3; diff --git a/api/src/org/labkey/api/assay/AssayProtocolSchema.java b/api/src/org/labkey/api/assay/AssayProtocolSchema.java index 8e51ce79517..042cbd8c1f0 100644 --- a/api/src/org/labkey/api/assay/AssayProtocolSchema.java +++ b/api/src/org/labkey/api/assay/AssayProtocolSchema.java @@ -41,8 +41,8 @@ import org.labkey.api.data.RenderContext; import org.labkey.api.data.Results; import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.ShowRows; import org.labkey.api.data.Sort; -import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.exp.PropertyDescriptor; import org.labkey.api.exp.api.ExpProtocol; @@ -711,7 +711,7 @@ public void addQCWarningIndicator(QueryView baseQueryView, ViewContext context, QuerySettings qs = getSettings(viewContext, settings.getDataRegionName(), settings.getQueryName()); // we want all the rows - qs.setMaxRows(Table.ALL_ROWS); + qs.setShowRows(ShowRows.ALL); QueryView allResultsQueryView = createAllResultsQueryView(viewContext, qs); DataView dataView = allResultsQueryView.createDataView(); diff --git a/api/src/org/labkey/api/data/DataRegion.java b/api/src/org/labkey/api/data/DataRegion.java index 0aeb5ec9a1b..6ce8ff60826 100644 --- a/api/src/org/labkey/api/data/DataRegion.java +++ b/api/src/org/labkey/api/data/DataRegion.java @@ -851,16 +851,7 @@ public Map> getAggregateResults(RenderContext ctx newAggregates.add(Aggregate.createCountStar()); _aggregateResults = ctx.getAggregates(_displayColumns, getTable(), getSettings(), getName(), newAggregates, getQueryParameters(), isAllowAsync()); - List result = _aggregateResults.remove(Aggregate.STAR); - - //Issue 14863: add null check - if (result != null && !result.isEmpty()) - { - Aggregate.Result countStarResult = result.getFirst(); - _totalRows = 0L; - if (countStarResult.getValue() instanceof Number) - _totalRows = ((Number) countStarResult.getValue()).longValue(); - } + setTotalRowsFromCountStar(_aggregateResults.remove(Aggregate.STAR)); } } else @@ -876,6 +867,42 @@ public Map> getAggregateResults(RenderContext ctx return _aggregateResults; } + /** Sets the total row count from a COUNT(*) aggregate alone, without selecting any rows. */ + public void loadTotalRowsOnly(RenderContext ctx) + { + if (!hasPermission(ctx, ReadPermission.class)) + throw new UnauthorizedException(); + + TableInfo table = getTable(); + if (null == table) + throw new NotFoundException("Table or query not found: " + getSettings().getQueryName()); + + DataRegion oldRegion = ctx.getCurrentRegion(); + ctx.setCurrentRegion(this); + try + { + Map> aggregates = ctx.getAggregates(_displayColumns, table, getSettings(), getName(), List.of(Aggregate.createCountStar()), getQueryParameters(), isAllowAsync()); + setTotalRowsFromCountStar(aggregates.get(Aggregate.STAR)); + _aggregateResults = Collections.emptyMap(); + } + finally + { + ctx.setCurrentRegion(oldRegion); + } + } + + private void setTotalRowsFromCountStar(@Nullable List result) + { + //Issue 14863: add null check + if (result == null || result.isEmpty()) + return; + + Aggregate.Result countStarResult = result.getFirst(); + _totalRows = 0L; + if (countStarResult.getValue() instanceof Number n) + _totalRows = n.longValue(); + } + @NotNull private List getSummaryStatsAggregates(List providers) { diff --git a/api/src/org/labkey/api/data/RenderContext.java b/api/src/org/labkey/api/data/RenderContext.java index db90b930c62..9aa89c5c0f1 100644 --- a/api/src/org/labkey/api/data/RenderContext.java +++ b/api/src/org/labkey/api/data/RenderContext.java @@ -350,7 +350,10 @@ public Map> getAggregates(List dis if (null != QueryService.get()) cols = QueryService.get().ensureRequiredColumns(tinfo, cols, filter, sort, ignoredAggregateFilters); - if (!ignoredAggregateFilters.equals(_ignoredColumnFilters)) + // Count-only requests skip the main query, so there's nothing to match; adopt the aggregate's set for filter messages + if (_results == null) + _ignoredColumnFilters.addAll(ignoredAggregateFilters); + else if (!ignoredAggregateFilters.equals(_ignoredColumnFilters)) { // This should never happen, but if it did, the totals wouldn't match, so we won't calculate them. _log.error("Aggregate filter columns do not match main. Aggregate:{} Main:{}", ignoredAggregateFilters, _ignoredColumnFilters); diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 7cea7215eef..497c6589497 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -3829,7 +3829,7 @@ if (!LABKEY.DataRegions) { containerPath: region.containerPath, filterArray: LABKEY.Filter.getFiltersFromParameters({ ...params, ...jsonData.filters }, params.dataRegionName), sort: undefined, - maxRows: 1, + maxRows: 0, // GH Issue 1607: with includeTotalCount, returns only the row count offset: 0, includeMetadata: false, includeDetailsColumn: false, diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 64743343c08..9b792f5c272 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -3457,6 +3457,13 @@ public void setIncludeTotalCount(boolean includeTotalCount) _includeTotalCount = includeTotalCount; } + /** True when the request sent includeTotalCount rather than leaving it at its default. */ + public boolean isIncludeTotalCountSet() + { + return null != getViewContext().getRequest().getParameter("includeTotalCount") || + (null != _initParameters && null != _initParameters.getPropertyValue("includeTotalCount")); + } + public boolean isIncludeStyle() { return _includeStyle; @@ -3577,7 +3584,10 @@ public ApiResponse execute(APIQueryForm form, BindException errors) } boolean isEditable = isQueryEditable(view.getTable()); - boolean metaDataOnly = form.getQuerySettings().getMaxRows() == 0; + boolean noRows = form.getQuerySettings().getMaxRows() == 0; + // GH Issue 1607: maxRows=0 with an explicit includeTotalCount=true returns just the row count + boolean countOnly = noRows && form.isIncludeTotalCount() && form.isIncludeTotalCountSet(); + boolean metaDataOnly = noRows && !countOnly; boolean arrayMultiValueColumns = getRequestedApiVersion() >= 16.2; boolean includeFormattedValue = getRequestedApiVersion() >= 17.1; @@ -3607,6 +3617,7 @@ else if (getRequestedApiVersion() >= 9.1) form.isIncludeDisplayValues(), form.isIncludeMetadata()); } response.includeStyle(form.isIncludeStyle()); + response.countOnly(countOnly); // Issues 29515 and 32269 - force key and other non-requested columns to be sent back, but only if the client has // requested minimal columns, as we now do for ExtJS stores