From 73eedb6aa408c0333116b472986d382d2de7c6b2 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 28 Sep 2026 14:11:46 -0500 Subject: [PATCH 1/7] SelectRowsAction treats maxRows=0 with an explicit includeTotalCount=true as a count-only request --- .../labkey/query/controllers/QueryController.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 From b82d3774108270353b227a3caea2f7e0a68a2690 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 28 Sep 2026 14:12:32 -0500 Subject: [PATCH 2/7] a new count-only path runs only the COUNT(*) and returns rowCount, metadata if requested --- .../labkey/api/action/ApiQueryResponse.java | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/api/src/org/labkey/api/action/ApiQueryResponse.java b/api/src/org/labkey/api/action/ApiQueryResponse.java index bbcaba1b7b9..2599eabda50 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,7 +181,10 @@ public void render(ApiResponseWriter writer) throws Exception _rowCount = _dataRegion.getTotalRows(); } } + } + if (!_metaDataOnly) + { long rowCount = _rowCount > 0 ? _rowCount : _offset + _numRespRows; writer.writeProperty("rowCount", rowCount); @@ -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; From 2156e5ad8c3d04ac1550f0d1d8d9b79030ca14c4 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 28 Sep 2026 14:13:15 -0500 Subject: [PATCH 3/7] adds loadTotalRowsOnly(), which checks read permission and runs COUNT(*) without a rows query --- api/src/org/labkey/api/data/DataRegion.java | 47 ++++++++++++++++----- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/api/src/org/labkey/api/data/DataRegion.java b/api/src/org/labkey/api/data/DataRegion.java index 0aeb5ec9a1b..478defdaf4a 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) + _totalRows = ((Number) countStarResult.getValue()).longValue(); + } + @NotNull private List getSummaryStatsAggregates(List providers) { From 2395455255c8d958da16637228235be4543f7be1 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 28 Sep 2026 14:13:45 -0500 Subject: [PATCH 4/7] getAggregates() no longer returns empty when no rows query has run --- api/src/org/labkey/api/data/RenderContext.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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); From 425737d7c9bba0825b548a54fc6b36e7f614fe3e Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 28 Sep 2026 14:49:22 -0500 Subject: [PATCH 5/7] Claude feedback: apply same maxRows change from 1 to 0 in loadAsyncTotalRowCount for LKS DataRegion.js --- api/src/org/labkey/api/action/ApiQueryResponse.java | 2 +- api/webapp/clientapi/dom/DataRegion.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/org/labkey/api/action/ApiQueryResponse.java b/api/src/org/labkey/api/action/ApiQueryResponse.java index 2599eabda50..66fc4d6af26 100644 --- a/api/src/org/labkey/api/action/ApiQueryResponse.java +++ b/api/src/org/labkey/api/action/ApiQueryResponse.java @@ -185,7 +185,7 @@ else if (_countOnly) if (!_metaDataOnly) { - long rowCount = _rowCount > 0 ? _rowCount : _offset + _numRespRows; + long rowCount = _rowCount > 0 || _countOnly ? _rowCount : _offset + _numRespRows; writer.writeProperty("rowCount", rowCount); if (_includeMetaData) 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, From 62b08f3fba79ca0d0d182849b77f235adf4fb398 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 28 Sep 2026 17:07:52 -0500 Subject: [PATCH 6/7] CR feedback --- api/src/org/labkey/api/data/DataRegion.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/org/labkey/api/data/DataRegion.java b/api/src/org/labkey/api/data/DataRegion.java index 478defdaf4a..6ce8ff60826 100644 --- a/api/src/org/labkey/api/data/DataRegion.java +++ b/api/src/org/labkey/api/data/DataRegion.java @@ -899,8 +899,8 @@ private void setTotalRowsFromCountStar(@Nullable List result) Aggregate.Result countStarResult = result.getFirst(); _totalRows = 0L; - if (countStarResult.getValue() instanceof Number) - _totalRows = ((Number) countStarResult.getValue()).longValue(); + if (countStarResult.getValue() instanceof Number n) + _totalRows = n.longValue(); } @NotNull From 4cc48252a2d7bf2bbdc464cfff0fe7e71abab715 Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 29 Sep 2026 09:28:28 -0500 Subject: [PATCH 7/7] AssayProtocolSchema fix to use setShowRows ShowRows.ALL for the assay QC warning's all-results query - A count-only request (maxRows=0) sets showRows to NONE, so setMaxRows(ALL_ROWS) hit the "Can't set maxRows when not paginated" assertion. --- api/src/org/labkey/api/assay/AssayProtocolSchema.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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();