Repository: ad-freiburg/qlever (language: python)

Issue:
changing maximum number of results returned
I've loaded Wikidata into a local QLever server and I am comparing it against the QLever Wikidata server at Freiburg.

For some queries the Freiburg returns more results (several million) but the response from my local server appears to be capped at one million results.  How can I change my local server to match the behaviour of the one at Freiburg?


Discussion / hints:

Fix patch:
diff --git a/src/engine/ExecuteUpdate.cpp b/src/engine/ExecuteUpdate.cpp
index 55564de97..f6c7ea43a 100644
--- a/src/engine/ExecuteUpdate.cpp
+++ b/src/engine/ExecuteUpdate.cpp
@@ -130,13 +130,13 @@ ExecuteUpdate::computeGraphUpdateQuads(
   auto graphUpdate = std::get<updateClause::GraphUpdate>(updateClause.op_);
   // Fully materialize the result for now. This makes it easier to execute the
   // update.
-  auto res = qet.getResult(false);
+  auto result = qet.getResult(false);
 
   const auto& vocab = index.getVocab();
 
   auto prepareTemplateAndResultContainer =
       [&vocab, &qet,
-       &res](std::vector<SparqlTripleSimpleWithGraph>&& tripleTemplates) {
+       &result](std::vector<SparqlTripleSimpleWithGraph>&& tripleTemplates) {
         auto [transformedTripleTemplates, localVocab] =
             transformTriplesTemplate(vocab, qet.getVariableColumns(),
                                      std::move(tripleTemplates));
@@ -144,7 +144,7 @@ ExecuteUpdate::computeGraphUpdateQuads(
         // The maximum result size is size(query result) x num template rows.
         // The actual result can be smaller if there are template rows with
         // variables for which a result row does not have a value.
-        updateTriples.reserve(res->idTable().size() *
+        updateTriples.reserve(result->idTable().size() *
                               transformedTripleTemplates.size());
 
         return std::tuple{std::move(transformedTripleTemplates),
@@ -156,8 +156,9 @@ ExecuteUpdate::computeGraphUpdateQuads(
   auto [toDeleteTemplates, toDelete, localVocabDelete] =
       prepareTemplateAndResultContainer(std::move(graphUpdate.toDelete_));
 
-  for (const auto& [pair, range] :
-       ExportQueryExecutionTrees::getRowIndices(query._limitOffset, *res)) {
+  uint64_t resultSize = 0;
+  for (const auto& [pair, range] : ExportQueryExecutionTrees::getRowIndices(
+           query._limitOffset, *result, resultSize)) {
     auto& idTable = pair.idTable_;
     for (const uint64_t i : range) {
       computeAndAddQuadsForResultRow(toInsertTemplates, toInsert, idTable, i);
diff --git a/src/engine/ExportQueryExecutionTrees.cpp b/src/engine/ExportQueryExecutionTrees.cpp
index 811ed4705..6cea915b5 100644
--- a/src/engine/ExportQueryExecutionTrees.cpp
+++ b/src/engine/ExportQueryExecutionTrees.cpp
@@ -1,6 +1,8 @@
 // Copyright 2022 - 2024, University of Freiburg
 // Chair of Algorithms and Data Structures
-// Author: Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
+// Authors: Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
+//          Robin Textor-Falconi <textorr@cs.uni-freiburg.de>
+//          Hannah Bast <bast@cs.uni-freiburg.de>
 
 #include "ExportQueryExecutionTrees.h"
 
@@ -83,31 +85,82 @@ ExportQueryExecutionTrees::getIdTables(const Result& result) {
   }
 }
 
-// Return a range that contains the indices of the rows that have to be exported
-// from the `idTable` given the `LimitOffsetClause`. It takes into account the
-// LIMIT, the OFFSET, and the actual size of the `idTable`
+// _____________________________________________________________________________
 cppcoro::generator<ExportQueryExecutionTrees::TableWithRange>
 ExportQueryExecutionTrees::getRowIndices(LimitOffsetClause limitOffset,
-                                         const Result& result) {
+                                         const Result& result,
+                                         uint64_t& resultSize) {
+  // The first call initializes the `resultSize` to zero (no need to
+  // initialize it outside of the function).
+  resultSize = 0;
+
+  // If the LIMIT is zero, there are no blocks to yield and the total result
+  // size is zero.
   if (limitOffset._limit.value_or(1) == 0) {
     co_return;
   }
+
+  // The effective offset, limit, and export limit. These will be updated after
+  // each block, see `updateEffectiveOffsetAndLimits` below. If they were not
+  // specified, they are initialized to their default values (0 for the offset
+  // and `std::numeric_limits<uint64_t>::max()` for the two limits).
+  uint64_t effectiveOffset = limitOffset._offset;
+  uint64_t effectiveLimit = limitOffset.limitOrDefault();
+  uint64_t effectiveExportLimit = limitOffset.exportLimitOrDefault();
+
+  // Make sure that the export limit is at most the limit (increasing the
+  // export limit beyond the limit has no effect).
+  effectiveExportLimit = std::min(effectiveExportLimit, effectiveLimit);
+
+  // Iterate over the result in blocks.
   for (TableConstRefWithVocab& tableWithVocab : getIdTables(result)) {
-    uint64_t currentOffset =
-        limitOffset.actualOffset(tableWithVocab.idTable_.numRows());
-    uint64_t upperBound =
-        limitOffset.upperBound(tableWithVocab.idTable_.numRows());
-    if (currentOffset != upperBound) {
-      co_yield {std::move(tableWithVocab),
-                std::views::iota(currentOffset, upperBound)};
+    // If all rows in the current block are before the effective offset, we can
+    // skip the block entirely. If not, there is at least something to count
+    // and maybe also something to yield.
+    uint64_t currentBlockSize = tableWithVocab.idTable_.numRows();
+    if (effectiveOffset >= currentBlockSize) {
+      effectiveOffset -= currentBlockSize;
+      continue;
     }
-    limitOffset._offset -= currentOffset;
-    if (limitOffset._limit.has_value()) {
-      limitOffset._limit =
-          limitOffset._limit.value() - (upperBound - currentOffset);
+    AD_CORRECTNESS_CHECK(effectiveOffset < currentBlockSize);
+    AD_CORRECTNESS_CHECK(effectiveLimit > 0);
+
+    // Compute the range of rows to be exported (can by zero) and to be counted
+    // (always non-zero at this point).
+    uint64_t rangeBegin = effectiveOffset;
+    uint64_t numRowsToBeExported =
+        std::min(effectiveExportLimit, currentBlockSize - rangeBegin);
+    uint64_t numRowsToBeCounted =
+        std::min(effectiveLimit, currentBlockSize - rangeBegin);
+    AD_CORRECTNESS_CHECK(rangeBegin + numRowsToBeExported <= currentBlockSize);
+    AD_CORRECTNESS_CHECK(rangeBegin + numRowsToBeCounted <= currentBlockSize);
+    AD_CORRECTNESS_CHECK(numRowsToBeCounted > 0);
+
+    // If there is something to be exported, yield it.
+    if (numRowsToBeExported > 0) {
+      co_yield {std::move(tableWithVocab),
+                std::views::iota(rangeBegin, rangeBegin + numRowsToBeExported)};
     }
-    if (limitOffset._limit.value_or(1) == 0) {
-      break;
+
+    // Add to `resultSize` and update the effective offset (which becomes zero
+    // after the first non-skipped block) and limits (make sure to never go
+    // below zero and `std::numeric_limits<uint64_t>::max()` stays there).
+    resultSize += numRowsToBeCounted;
+    effectiveOffset = 0;
+    auto reduceLimit = [&](uint64_t& limit, uint64_t subtrahend) {
+      if (limit != std::numeric_limits<uint64_t>::max()) {
+        limit = limit > subtrahend ? limit - subtrahend : 0;
+      }
+    };
+    reduceLimit(effectiveLimit, numRowsToBeCounted);
+    reduceLimit(effectiveExportLimit, numRowsToBeCounted);
+
+    // If the effective limit is zero, there is nothing to yield and nothing
+    // to count anymore. This should come at the end of this loop and not at
+    // the beginning, to avoid unnecessarily fetching another block from
+    // `result`.
+    if (effectiveLimit == 0) {
+      co_return;
     }
   }
 }
@@ -118,8 +171,9 @@ ExportQueryExecutionTrees::constructQueryResultToTriples(
     const QueryExecutionTree& qet,
     const ad_utility::sparql_types::Triples& constructTriples,
     LimitOffsetClause limitAndOffset, std::shared_ptr<const Result> result,
-    CancellationHandle cancellationHandle) {
-  for (const auto& [pair, range] : getRowIndices(limitAndOffset, *result)) {
+    uint64_t& resultSize, CancellationHandle cancellationHandle) {
+  for (const auto& [pair, range] :
+       getRowIndices(limitAndOffset, *result, resultSize)) {
     auto& idTable = pair.idTable_;
     for (uint64_t i : range) {
       ConstructQueryExportContext context{i, idTable, pair.localVocab_,
@@ -140,6 +194,11 @@ ExportQueryExecutionTrees::constructQueryResultToTriples(
       }
     }
   }
+  // For each result from the WHERE clause, we produce up to
+  // `constructTriples.size()` triples. We do not account for triples that are
+  // filtered out because one of the components is UNDEF (it would require
+  // materializing the whole result).
+  resultSize *= constructTriples.size();
 }
 
 // _____________________________________________________________________________
@@ -151,9 +210,10 @@ ad_utility::streams::stream_generator ExportQueryExecutionTrees::
         LimitOffsetClause limitAndOffset, std::shared_ptr<const Result> result,
         CancellationHandle cancellationHandle) {
   result->logResultSize();
-  auto generator =
-      constructQueryResultToTriples(qet, constructTriples, limitAndOffset,
-                                    result, std::move(cancellationHandle));
+  [[maybe_unused]] uint64_t resultSize = 0;
+  auto generator = constructQueryResultToTriples(
+      qet, constructTriples, limitAndOffset, result, resultSize,
+      std::move(cancellationHandle));
   for (const auto& triple : generator) {
     co_yield triple.subject_;
     co_yield ' ';
@@ -182,11 +242,12 @@ cppcoro::generator<std::string>
 ExportQueryExecutionTrees::constructQueryResultBindingsToQLeverJSON(
     const QueryExecutionTree& qet,
     const ad_utility::sparql_types::Triples& constructTriples,
-    const LimitOffsetClause& limitAndOffset, std::shared_ptr<const Result> res,
+    const LimitOffsetClause& limitAndOffset,
+    std::shared_ptr<const Result> result, uint64_t& resultSize,
     CancellationHandle cancellationHandle) {
-  auto generator = constructQueryResultToTriples(qet, constructTriples,
-                                                 limitAndOffset, std::move(res),
-                                                 std::move(cancellationHandle));
+  auto generator = constructQueryResultToTriples(
+      qet, constructTriples, limitAndOffset, std::move(result), resultSize,
+      std::move(cancellationHandle));
   for (auto& triple : generator) {
     auto binding = nlohmann::json::array({std::move(triple.subject_),
                                           std::move(triple.predicate_),
@@ -230,12 +291,13 @@ nlohmann::json idTableToQLeverJSONRow(
 // _____________________________________________________________________________
 cppcoro::generator<std::string>
 ExportQueryExecutionTrees::idTableToQLeverJSONBindings(
-    const QueryExecutionTree& qet, const LimitOffsetClause& limitAndOffset,
+    const QueryExecutionTree& qet, LimitOffsetClause limitAndOffset,
     const QueryExecutionTree::ColumnIndicesAndTypes columns,
-    std::shared_ptr<const Result> result,
+    std::shared_ptr<const Result> result, uint64_t& resultSize,
     CancellationHandle cancellationHandle) {
   AD_CORRECTNESS_CHECK(result != nullptr);
-  for (const auto& [pair, range] : getRowIndices(limitAndOffset, *result)) {
+  for (const auto& [pair, range] :
+       getRowIndices(limitAndOffset, *result, resultSize)) {
     for (uint64_t rowIndex : range) {
       co_yield idTableToQLeverJSONRow(qet, columns, pair.localVocab_, rowIndex,
                                       pair.idTable_)
@@ -440,7 +502,7 @@ ExportQueryExecutionTrees::selectQueryResultBindingsToQLeverJSON(
     const QueryExecutionTree& qet,
     const parsedQuery::SelectClause& selectClause,
     const LimitOffsetClause& limitAndOffset,
-    std::shared_ptr<const Result> result,
+    std::shared_ptr<const Result> result, uint64_t& resultSize,
     CancellationHandle cancellationHandle) {
   AD_CORRECTNESS_CHECK(result != nullptr);
   LOG(DEBUG) << "Resolving strings for finished binary result...\n";
@@ -448,7 +510,7 @@ ExportQueryExecutionTrees::selectQueryResultBindingsToQLeverJSON(
       qet.selectedVariablesToColumnIndices(selectClause, true);
 
   return idTableToQLeverJSONBindings(qet, limitAndOffset, selectedColumnIndices,
-                                     std::move(result),
+                                     std::move(result), resultSize,
                                      std::move(cancellationHandle));
 }
 
@@ -479,7 +541,9 @@ ExportQueryExecutionTrees::selectQueryResultToStream(
 
   // special case : binary export of IdTable
   if constexpr (format == MediaType::octetStream) {
-    for (const auto& [pair, range] : getRowIndices(limitAndOffset, *result)) {
+    uint64_t resultSize = 0;
+    for (const auto& [pair, range] :
+         getRowIndices(limitAndOffset, *result, resultSize)) {
       for (uint64_t i : range) {
         for (const auto& columnIndex : selectedColumnIndices) {
           if (columnIndex.has_value()) {
@@ -510,7 +574,9 @@ ExportQueryExecutionTrees::selectQueryResultToStream(
   constexpr auto& escapeFunction = format == MediaType::tsv
                                        ? RdfEscaping::escapeForTsv
                                        : RdfEscaping::escapeForCsv;
-  for (const auto& [pair, range] : getRowIndices(limitAndOffset, *result)) {
+  uint64_t resultSize = 0;
+  for (const auto& [pair, range] :
+       getRowIndices(limitAndOffset, *result, resultSize)) {
     for (uint64_t i : range) {
       for (size_t j = 0; j < selectedColumnIndices.size(); ++j) {
         if (selectedColumnIndices[j].has_value()) {
@@ -635,7 +701,9 @@ ad_utility::streams::stream_generator ExportQueryExecutionTrees::
   auto selectedColumnIndices =
       qet.selectedVariablesToColumnIndices(selectClause, false);
   // TODO<joka921> we could prefilter for the nonexisting variables.
-  for (const auto& [pair, range] : getRowIndices(limitAndOffset, *result)) {
+  uint64_t resultSize = 0;
+  for (const auto& [pair, range] :
+       getRowIndices(limitAndOffset, *result, resultSize)) {
     for (uint64_t i : range) {
       co_yield "\n  <result>";
       for (size_t j = 0; j < selectedColumnIndices.size(); ++j) {
@@ -702,7 +770,9 @@ ad_utility::streams::stream_generator ExportQueryExecutionTrees::
   };
 
   bool isFirstRow = true;
-  for (const auto& [pair, range] : getRowIndices(limitAndOffset, *result)) {
+  uint64_t resultSize = 0;
+  for (const auto& [pair, range] :
+       getRowIndices(limitAndOffset, *result, resultSize)) {
     for (uint64_t i : range) {
       if (!isFirstRow) [[likely]] {
         co_yield ",";
@@ -743,9 +813,10 @@ ExportQueryExecutionTrees::constructQueryResultToStream(
                                        ? RdfEscaping::escapeForTsv
                                        : RdfEscaping::escapeForCsv;
   constexpr char sep = format == MediaType::tsv ? '\t' : ',';
-  auto generator =
-      constructQueryResultToTriples(qet, constructTriples, limitAndOffset,
-                                    result, std::move(cancellationHandle));
+  [[maybe_unused]] uint64_t resultSize = 0;
+  auto generator = constructQueryResultToTriples(
+      qet, constructTriples, limitAndOffset, result, resultSize,
+      std::move(cancellationHandle));
   for (auto& triple : generator) {
     co_yield escapeFunction(std::move(triple.subject_));
     co_yield sep;
@@ -857,28 +928,34 @@ ExportQueryExecutionTrees::computeResultAsQLeverJSON(
   co_yield absl::StrCat(prefixStr.substr(0, prefixStr.size() - 1),
                         R"(,"res":[)");
 
+  // Yield the bindings and compute the result size.
+  uint64_t resultSize = 0;
   auto bindings = [&]() {
     if (query.hasSelectClause()) {
       return selectQueryResultBindingsToQLeverJSON(
           qet, query.selectClause(), query._limitOffset, std::move(result),
-          std::move(cancellationHandle));
+          resultSize, std::move(cancellationHandle));
     } else if (query.hasConstructClause()) {
       return constructQueryResultBindingsToQLeverJSON(
           qet, query.constructClause().triples_, query._limitOffset,
-          std::move(result), std::move(cancellationHandle));
+          std::move(result), resultSize, std::move(cancellationHandle));
     } else {
       // TODO<joka921>: Refactor this to use std::visit.
       return askQueryResultToQLeverJSON(std::move(result));
     }
   }();
 
-  size_t resultSize = 0;
+  size_t numBindingsExported = 0;
   for (const std::string& b : bindings) {
-    if (resultSize > 0) [[likely]] {
+    if (numBindingsExported > 0) [[likely]] {
       co_yield ",";
     }
     co_yield b;
-    ++resultSize;
+    ++numBindingsExported;
+  }
+  if (numBindingsExported < resultSize) {
+    LOG(INFO) << "Number of bindings exported: " << numBindingsExported
+              << " of " << resultSize << std::endl;
   }
 
   RuntimeInformation runtimeInformation = qet.getRootOperation()->runtimeInfo();
@@ -888,11 +965,29 @@ ExportQueryExecutionTrees::computeResultAsQLeverJSON(
       std::chrono::duration_cast<std::chrono::milliseconds>(
           timeUntilFunctionCall + runtimeInformation.totalTime_);
 
+  // NOTE: We report three "results sizes" in the QLever JSON output, for the
+  // following reasons:
+  //
+  // The `resultSizeExported` is the number of bindings exported. This is
+  // redundant information (we could simply count the number of entries in the
+  // `res` array), but it is useful for testing and emphasizes the conceptual
+  // difference to `resultSizeTotal`.
+  //
+  // The `resultSizeTotal` is the number of results of the WHOLE query. For
+  // CONSTRUCT queries, it can be an overestimate because it also includes
+  // triples, where one of the components is UNDEF, which are not included
+  // in the final result of a CONSTRUCT query.
+  //
+  // The `resultsize` is equal to `resultSizeTotal`. It is included for
+  // backwards compatibility, in particular, because the QLever UI uses it
+  // at many places.
   nlohmann::json jsonSuffix;
   jsonSuffix["runtimeInformation"]["meta"] = nlohmann::ordered_json(
       qet.getRootOperation()->getRuntimeInfoWholeQuery());
   jsonSuffix["runtimeInformation"]["query_execution_tree"] =
       nlohmann::ordered_json(runtimeInformation);
+  jsonSuffix["resultSizeExported"] = numBindingsExported;
+  jsonSuffix["resultSizeTotal"] = resultSize;
   jsonSuffix["resultsize"] = resultSize;
   jsonSuffix["time"]["total"] =
       absl::StrCat(requestTimer.msecs().count(), "ms");
diff --git a/src/engine/ExportQueryExecutionTrees.h b/src/engine/ExportQueryExecutionTrees.h
index 91a37b6d4..a1443e802 100644
--- a/src/engine/ExportQueryExecutionTrees.h
+++ b/src/engine/ExportQueryExecutionTrees.h
@@ -1,6 +1,8 @@
-//  Copyright 2022, University of Freiburg,
-//                  Chair of Algorithms and Data Structures.
-//  Author: Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
+// Copyright 2022 - 2024, University of Freiburg
+// Chair of Algorithms and Data Structures
+// Authors: Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
+//          Robin Textor-Falconi <textorr@cs.uni-freiburg.de>
+//          Hannah Bast <bast@cs.uni-freiburg.de>
 
 #pragma once
 
@@ -10,14 +12,9 @@
 #include "util/http/MediaTypes.h"
 #include "util/json.h"
 
-// This class contains all the functionality to convert a query that has already
-// been parsed (by the SPARQL parser) and planned (by the query planner) into
-// a serialized result. In particular, it creates TSV, CSV, Turtle, JSON (SPARQL
-// conforming and QLever's flavor) and binary results).
-// This class has only two static public functions, one for the JSON
-// output format (which returns a `nlohmann::json` object) and one for the other
-// result formats (tsv, csv, turtle, binary) which returns a
-// `streamable_generator`.
+// Class for computing the result of an already parsed and planned query and
+// exporting it in different formats (TSV, CSV, Turtle, JSON, Binary).
+//
 // TODO<joka921> Also implement a streaming JSON serializer to reduce the RAM
 // consumption of large JSON exports and to make this interface even simpler.
 class ExportQueryExecutionTrees {
@@ -41,18 +38,6 @@ class ExportQueryExecutionTrees {
       MediaType mediaType, const ad_utility::Timer& requestTimer,
       CancellationHandle cancellationHandle);
 
-  // Compute the result of the given `parsedQuery` (created by the
-  // `SparqlParser`) for which the `QueryExecutionTree` has been previously
-  // created by the `QueryPlanner`. The result is converted to the format
-  // specified by the `mediaType`. Supported formats for this function are
-  // `SparqlJSON` and `QLeverJSON`. Note that the SparqlJSON format can only be
-  // used with SELECT queries. Invalid `mediaType`s and invalid combinations of
-  // `mediaType` and the query type will throw. The result is returned as a
-  // single JSON object that is fully materialized before the function returns.
-  // The `requestTimer` is used to report timing statistics on the query. It
-  // must have already run during the query planning to produce the expected
-  // results.
-
   // Convert the `id` to a human-readable string. The `index` is used to resolve
   // `Id`s with datatype `VocabIndex` or `TextRecordIndex`. The `localVocab` is
   // used to resolve `Id`s with datatype `LocalVocabIndex`. The `escapeFunction`
@@ -105,66 +90,64 @@ class ExportQueryExecutionTrees {
       ad_utility::streams::stream_generator streamGenerator);
 
  private:
-  // Similar to `computeResult` but returns a stream in
-  // QLeverJSON-format.
+  // Generate the bindings of the result of a SELECT or CONSTRUCT query in the
+  // `application/qlever-results+json` format.
+  //
+  // NOTE: This calls `selectQueryResultBindingsToQLeverJSON` or
+  // `constructQueryResultBindingsToQLeverJSON` for the bindings and adds the
+  // remaining (meta) fields needed for the `application/qlever-results+json`
+  // format.
   static ad_utility::streams::stream_generator computeResultAsQLeverJSON(
       const ParsedQuery& query, const QueryExecutionTree& qet,
       const ad_utility::Timer& requestTimer,
       CancellationHandle cancellationHandle);
 
-  // ___________________________________________________________________________
+  // Generate the bindings of the result of a SELECT query in the
+  // `application/ qlever+json` format.
   static cppcoro::generator<std::string> selectQueryResultBindingsToQLeverJSON(
       const QueryExecutionTree& qet,
       const parsedQuery::SelectClause& selectClause,
       const LimitOffsetClause& limitAndOffset,
-      std::shared_ptr<const Result> result,
-      CancellationHandle cancellationHandle);
-  /**
-   * @brief Convert an `IdTable` (typically from a query result) to a JSON
-   * array In the `QLeverJSON` format. This function is called by
-   *  `computeQueryResultAsQLeverJSON` to obtain the "actual" query results
-   * (without the meta data)
-   * @param qet The `QueryExecutionTree` of the query.
-   * @param limitAndOffset at most <limit> entries are written, starting at
-   * <from>
-   * @param columns each pair of <columnInIdTable, correspondingType> tells
-   * us which columns are to be serialized in which order
-   * @param resultTable The query result in the ID space. If it is `nullptr`,
-   *        then the query result will be obtained via `qet->getResult()`.
-   * @return a 2D-Json array corresponding to the IdTable given the arguments
-   */
-  static cppcoro::generator<std::string> idTableToQLeverJSONBindings(
-      const QueryExecutionTree& qet, const LimitOffsetClause& limitAndOffset,
-      const QueryExecutionTree::ColumnIndicesAndTypes columns,
-      std::shared_ptr<const Result> result,
+      std::shared_ptr<const Result> result, uint64_t& resultSize,
       CancellationHandle cancellationHandle);
 
-  // ___________________________________________________________________________
+  // Generate the bindings of the result of a CONSTRUCT query in the
+  // `application/ qlever+json` format.
   static cppcoro::generator<std::string>
   constructQueryResultBindingsToQLeverJSON(
       const QueryExecutionTree& qet,
       const ad_utility::sparql_types::Triples& constructTriples,
       const LimitOffsetClause& limitAndOffset,
-      std::shared_ptr<const Result> res, CancellationHandle cancellationHandle);
+      std::shared_ptr<const Result> result, uint64_t& resultSize,
+      CancellationHandle cancellationHandle);
 
-  // Generate an RDF graph for a CONSTRUCT query.
+  // Helper function that generates the individual bindings for the
+  // `application/ qlever+json` format.
+  static cppcoro::generator<std::string> idTableToQLeverJSONBindings(
+      const QueryExecutionTree& qet, LimitOffsetClause limitAndOffset,
+      const QueryExecutionTree::ColumnIndicesAndTypes columns,
+      std::shared_ptr<const Result> result, uint64_t& resultSize,
+      CancellationHandle cancellationHandle);
+
+  // Helper function that generates the result of a CONSTRUCT query as
+  // `StringTriple`s.
   static cppcoro::generator<QueryExecutionTree::StringTriple>
   constructQueryResultToTriples(
       const QueryExecutionTree& qet,
       const ad_utility::sparql_types::Triples& constructTriples,
-      LimitOffsetClause limitAndOffset, std::shared_ptr<const Result> res,
-      CancellationHandle cancellationHandle);
+      LimitOffsetClause limitAndOffset, std::shared_ptr<const Result> result,
+      uint64_t& resultSize, CancellationHandle cancellationHandle);
 
-  // ___________________________________________________________________________
+  // Helper function that generates the result of a CONSTRUCT query as a
+  // CSV or TSV stream.
   template <MediaType format>
   static ad_utility::streams::stream_generator constructQueryResultToStream(
       const QueryExecutionTree& qet,
       const ad_utility::sparql_types::Triples& constructTriples,
-      LimitOffsetClause limitAndOffset,
-      std::shared_ptr<const Result> resultTable,
+      LimitOffsetClause limitAndOffset, std::shared_ptr<const Result> result,
       CancellationHandle cancellationHandle);
 
-  // _____________________________________________________________________________
+  // Generate the result of a SELECT query as a CSV or TSV or binary stream.
   template <MediaType format>
   static ad_utility::streams::stream_generator selectQueryResultToStream(
       const QueryExecutionTree& qet,
@@ -189,12 +172,26 @@ class ExportQueryExecutionTrees {
   static cppcoro::generator<ExportQueryExecutionTrees::TableConstRefWithVocab>
   getIdTables(const Result& result);
 
-  // Return a range that contains the indices of the rows that have to be
-  // exported from the `idTable` given the `LimitOffsetClause`. It takes into
-  // account the LIMIT, the OFFSET, and the actual size of the `idTable`
+  // Generate the result in "blocks" and, when iterating over the generator
+  // from beginning to end, return the total number of rows in the result
+  // in `totalResultSize`.
+  //
+  // Blocks, where all rows are before OFFSET, are requested (and hence
+  // computed), but skipped.
+  //
+  // Blocks, where at least one row is after OFFSET but before the effective
+  // export limit (minimum of the LIMIT and the value of the `send` parameter),
+  // are requested and yielded (together with the corresponding `LocalVocab`
+  // and the range from that `IdTable` that belongs to the result).
+  //
+  // Blocks after the effective export limit until the LIMIT are requested, and
+  // counted towards the `totalResultSize`, but not yielded.
+  //
+  // Blocks after the LIMIT are not even requested.
  public:
   static cppcoro::generator<TableWithRange> getRowIndices(
-      LimitOffsetClause limitOffset, const Result& result);
+      LimitOffsetClause limitOffset, const Result& result,
+      uint64_t& resutSizeTotal);
 
  private:
   FRIEND_TEST(ExportQueryExecutionTrees, getIdTablesReturnsSingletonIterator);
diff --git a/src/engine/Operation.cpp b/src/engine/Operation.cpp
index bb7d8fb0a..9c6ae814a 100644
--- a/src/engine/Operation.cpp
+++ b/src/engine/Operation.cpp
@@ -433,7 +433,8 @@ void Operation::createRuntimeInfoFromEstimates(
 
   _runtimeInfo->costEstimate_ = getCostEstimate();
   _runtimeInfo->sizeEstimate_ = getSizeEstimateBeforeLimit();
-  const auto& [limit, offset, _] = getLimit();
+  // We are interested only in the first two elements of the limit tuple.
+  const auto& [limit, offset, _1, _2] = getLimit();
   if (limit.has_value()) {
     _runtimeInfo->addDetail("limit", limit.value());
   }
diff --git a/src/engine/Server.cpp b/src/engine/Server.cpp
index 5a5085a23..87ca3897f 100644
--- a/src/engine/Server.cpp
+++ b/src/engine/Server.cpp
@@ -1,4 +1,4 @@
-// Copyright 2011 - 2022, University of Freiburg
+// Copyright 2011 - 2024, University of Freiburg
 // Chair of Algorithms and Data Structures
 // Authors: Björn Buchhold <b.buchhold@gmail.com>
 //          Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
@@ -761,19 +761,6 @@ Awaitable<void> Server::processQuery(
   LOG(INFO) << "Requested media type of result is \""
             << ad_utility::toString(mediaType) << "\"" << std::endl;
 
-  // TODO<c++23> use std::optional::transform
-  std::optional<uint64_t> maxSend = std::nullopt;
-  auto parameterValue =
-      ad_utility::url_parser::getParameterCheckAtMostOnce(params, "send");
-  if (parameterValue.has_value()) {
-    maxSend = std::stoul(parameterValue.value());
-  }
-  // Limit JSON requests by default
-  if (!maxSend.has_value() && (mediaType == MediaType::sparqlJson ||
-                               mediaType == MediaType::qleverJson)) {
-    maxSend = MAX_NOF_ROWS_IN_RESULT;
-  }
-
   auto queryHub = queryHub_.lock();
   AD_CORRECTNESS_CHECK(queryHub);
   ad_utility::websocket::MessageSender messageSender{getQueryId(request, query),
@@ -800,22 +787,27 @@ Awaitable<void> Server::processQuery(
     // This may be caused by a bug (the code is not yet tested well) or by an
     // attack which tries to circumvent (not yet existing) access controls for
     // Update.
-    throw std::runtime_error("Expected Query but received Update.");
+    throw std::runtime_error("Expected normal query but received update query");
   }
 
-  // Apply stricter limit for export if present
-  if (maxSend.has_value()) {
-    auto& pq = plannedQuery.parsedQuery_;
-    pq._limitOffset._limit =
-        std::min(maxSend.value(), pq._limitOffset.limitOrDefault());
+  // Read the export limit from the send` parameter (historical name). This
+  // limits the number of bindings exported in `ExportQueryExecutionTrees`.
+  // It should only have an effect for the QLever JSON export.
+  auto& limitOffset = plannedQuery.parsedQuery_._limitOffset;
+  auto& exportLimit = limitOffset.exportLimit_;
+  auto sendParameter =
+      ad_utility::url_parser::getParameterCheckAtMostOnce(params, "send");
+  if (sendParameter.has_value() && mediaType == MediaType::qleverJson) {
+    exportLimit = std::stoul(sendParameter.value());
   }
-  // Make sure we don't underflow here
-  AD_CORRECTNESS_CHECK(plannedQuery.parsedQuery_._limitOffset._offset >=
+
+  // Make sure that the offset is not applied again when exporting the result
+  // (it is already applied by the root operation in the query execution
+  // tree). Note that we don't need this for the limit because applying a
+  // fixed limit is idempotent.
+  AD_CORRECTNESS_CHECK(limitOffset._offset >=
                        qet.getRootOperation()->getLimit()._offset);
-  // Don't apply offset twice, if the offset was not applied to the operation
-  // then the exporter can safely apply it during export.
-  plannedQuery.parsedQuery_._limitOffset._offset -=
-      qet.getRootOperation()->getLimit()._offset;
+  limitOffset._offset -= qet.getRootOperation()->getLimit()._offset;
 
   // This actually processes the query and sends the result in the requested
   // format.
@@ -824,6 +816,9 @@ Awaitable<void> Server::processQuery(
 
   // Print the runtime info. This needs to be done after the query
   // was computed.
+  LOG(INFO) << "Done processing query and sending result"
+            << ", total time was " << requestTimer.msecs().count() << " ms"
+            << std::endl;
 
   // Log that we are done with the query and how long it took.
   //
@@ -833,9 +828,6 @@ Awaitable<void> Server::processQuery(
   // contain timing information).
   //
   // TODO<joka921> Also log an identifier of the query.
-  LOG(INFO) << "Done processing query and sending result"
-            << ", total time was " << requestTimer.msecs().count() << " ms"
-            << std::endl;
   LOG(DEBUG) << "Runtime Info:\n"
              << qet.getRootOperation()->runtimeInfo().toString() << std::endl;
   co_return;
diff --git a/src/engine/Server.h b/src/engine/Server.h
index 7abaf3eaf..e7e514adf 100644
--- a/src/engine/Server.h
+++ b/src/engine/Server.h
@@ -1,4 +1,4 @@
-// Copyright 2021 - 2022, University of Freiburg
+// Copyright 2021 - 2024, University of Freiburg
 // Chair of Algorithms and Data Structures
 // Authors: Johannes Kalmbach<kalmbach@cs.uni-freiburg.de>
 //          Hannah Bast <bast@cs.uni-freiburg.de>
diff --git a/src/index/DeltaTriples.h b/src/index/DeltaTriples.h
index afe13c7c0..329bd1333 100644
--- a/src/index/DeltaTriples.h
+++ b/src/index/DeltaTriples.h
@@ -105,6 +105,7 @@ class DeltaTriples {
   explicit DeltaTriples(const Index& index);
   explicit DeltaTriples(const IndexImpl& index) : index_{index} {};
 
+  // Disable accidental copying.
   DeltaTriples(const DeltaTriples&) = delete;
   DeltaTriples& operator=(const DeltaTriples&) = delete;
 
diff --git a/src/parser/data/LimitOffsetClause.h b/src/parser/data/LimitOffsetClause.h
index 36442d9c5..63b51932e 100644
--- a/src/parser/data/LimitOffsetClause.h
+++ b/src/parser/data/LimitOffsetClause.h
@@ -1,6 +1,8 @@
-//  Copyright 2022, University of Freiburg,
-//  Chair of Algorithms and Data Structures.
-//  Author: Julian Mundhahs (mundhahj@informatik.uni-freiburg.de)
+// Copyright 2022 - 2024, University of Freiburg
+// Chair of Algorithms and Data Structures
+// Authors: Julian Mundhahs <mundhahj@cs.uni-freiburg.de>
+//          Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
+//          Hannah Bast <bast@cs.uni-freiburg.de>
 
 #pragma once
 
@@ -12,15 +14,19 @@
 
 // Represents the data returned by a limitOffsetClause.
 struct LimitOffsetClause {
-  std::optional<uint64_t> _limit;
+  std::optional<uint64_t> _limit = std::nullopt;
   uint64_t _offset = 0;
   std::optional<uint64_t> textLimit_ = std::nullopt;
+  std::optional<uint64_t> exportLimit_ = std::nullopt;
 
   // If a limit is specified, return the limit, else return the maximal
   // representable limit.
   uint64_t limitOrDefault() const {
     return _limit.value_or(std::numeric_limits<uint64_t>::max());
   }
+  uint64_t exportLimitOrDefault() const {
+    return exportLimit_.value_or(std::numeric_limits<uint64_t>::max());
+  }
 
   // Return the minimum of the offset and the `actualSize` of a query result.
   // That way, if the offset is too large, the result after applying it will be
@@ -30,10 +36,10 @@ struct LimitOffsetClause {
     return std::min(actualSize, _offset);
   }
 
-  // When applying the limit and offset to a table of `actualSize`, what is the
-  // actual upper bound (as an index into the table) for the resulting elements.
-  // In the most simple case this is `limit + offset`, but this function handles
-  // all possible overflows. The result will always be `<= actualSize`.
+  // Return the largest index into a table of size `actualSize` when applying
+  // the limit and offset. When a limit and offset are specified and the table
+  // is large enough, this is simply `limit + offset`. Otherwise, it is
+  // appropriately clamped.
   uint64_t upperBound(uint64_t actualSize) const {
     auto val = limitOrDefault() + _offset;
     val = val >= std::max(limitOrDefault(), _offset)


Test patch:
diff --git a/test/ExecuteUpdateTest.cpp b/test/ExecuteUpdateTest.cpp
index 5367d8ec7..83015cac3 100644
--- a/test/ExecuteUpdateTest.cpp
+++ b/test/ExecuteUpdateTest.cpp
@@ -32,14 +32,14 @@ MATCHER_P(AlwaysFalse, msg, "") {
   return false;
 }
 
-// _____________________________________________________________________________
+// Test the `ExecuteUpdate::executeUpdate` method. These tests run on the
+// default dataset defined in `IndexTestHelpers::makeTestIndex`.
 TEST(ExecuteUpdate, executeUpdate) {
-  auto executeUpdate = [](const std::string& update) {
-    // These tests run on the default dataset defined in
-    // `IndexTestHelpers::makeTestIndex`.
-    QueryExecutionContext* qec = ad_utility::testing::getQec(std::nullopt);
-    const Index& index = qec->getIndex();
-    DeltaTriples deltaTriples{index};
+  QueryExecutionContext* qec = ad_utility::testing::getQec(std::nullopt);
+  const Index& index = qec->getIndex();
+  // Perform the given `update` and store result in given `deltaTriples`.
+  auto expectExecuteUpdateHelper = [&qec, &index](const std::string& update,
+                                                  DeltaTriples& deltaTriples) {
     const auto sharedHandle =
         std::make_shared<ad_utility::CancellationHandle<>>();
     const std::vector<DatasetClause> datasets = {};
@@ -47,20 +47,26 @@ TEST(ExecuteUpdate, executeUpdate) {
     QueryPlanner qp{qec, sharedHandle};
     const auto qet = qp.createExecutionTree(pq);
     ExecuteUpdate::executeUpdate(index, pq, qet, deltaTriples, sharedHandle);
-    return deltaTriples;
   };
+  // Execute the given `update` and check that the delta triples are correct.
   auto expectExecuteUpdate =
-      [&executeUpdate](
+      [&index, &expectExecuteUpdateHelper](
           const std::string& update,
           const testing::Matcher<const DeltaTriples&>& deltaTriplesMatcher) {
-        EXPECT_THAT(executeUpdate(update), deltaTriplesMatcher);
+        DeltaTriples deltaTriples{index};
+        expectExecuteUpdateHelper(update, deltaTriples);
+        EXPECT_THAT(deltaTriples, deltaTriplesMatcher);
       };
+  // Execute the given `update` and check that it fails with the given message.
   auto expectExecuteUpdateFails =
-      [&executeUpdate](
+      [&index, &expectExecuteUpdateHelper](
           const std::string& update,
           const testing::Matcher<const std::string&>& messageMatcher) {
-        AD_EXPECT_THROW_WITH_MESSAGE(executeUpdate(update), messageMatcher);
+        DeltaTriples deltaTriples{index};
+        AD_EXPECT_THROW_WITH_MESSAGE(
+            expectExecuteUpdateHelper(update, deltaTriples), messageMatcher);
       };
+  // Now the actual tests.
   expectExecuteUpdate("INSERT DATA { 
 <p> <o> . }", NumTriples(1, 0, 1));
   expectExecuteUpdate("DELETE DATA { <z> <label> \"zz\"@en }",
                       NumTriples(0, 1, 1));
diff --git a/test/ExportQueryExecutionTreesTest.cpp b/test/ExportQueryExecutionTreesTest.cpp
index 081bef958..663bc46fd 100644
--- a/test/ExportQueryExecutionTreesTest.cpp
+++ b/test/ExportQueryExecutionTreesTest.cpp
@@ -1,6 +1,8 @@
-//  Copyright 2023, University of Freiburg,
-//                  Chair of Algorithms and Data Structures.
-//  Author: Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
+// Copyright 2023 - 2024, University of Freiburg
+// Chair of Algorithms and Data Structures
+// Authors: Johannes Kalmbach <kalmbach@cs.uni-freiburg.de>
+//          Robin Textor-Falconi <robintf@cs.uni-freiburg.de>
+//          Hannah Bast <bast@cs.uni-freiburg.de>
 
 #include <gmock/gmock.h>
 
@@ -24,10 +26,10 @@ using ::testing::HasSubstr;
 namespace {
 // Run the given SPARQL `query` on the given Turtle `kg` and export the result
 // as the `mediaType`. `mediaType` must be TSV or CSV.
-std::string runQueryStreamableResult(const std::string& kg,
-                                     const std::string& query,
-                                     ad_utility::MediaType mediaType,
-                                     bool useTextIndex = false) {
+std::string runQueryStreamableResult(
+    const std::string& kg, const std::string& query,
+    ad_utility::MediaType mediaType, bool useTextIndex = false,
+    std::optional<size_t> exportLimit = std::nullopt) {
   auto qec =
       ad_utility::testing::getQec(kg, true, true, true, 16_B, useTextIndex);
   // TODO<joka921> There is a bug in the caching that we have yet to trace.
@@ -37,6 +39,7 @@ std::string runQueryStreamableResult(const std::string& kg,
       std::make_shared<ad_utility::CancellationHandle<>>();
   QueryPlanner qp{qec, cancellationHandle};
   auto pq = SparqlParser::parseQuery(query);
+  pq._limitOffset.exportLimit_ = exportLimit;
   auto qet = qp.createExecutionTree(pq);
   ad_utility::Timer timer(ad_utility::Timer::Started);
   auto strGenerator = ExportQueryExecutionTrees::computeResult(
@@ -78,7 +81,7 @@ nlohmann::json runJSONQuery(const std::string& kg, const std::string& query,
 struct TestCaseSelectQuery {
   std::string kg;                   // The knowledge graph (TURTLE)
   std::string query;                // The query (SPARQL)
-  size_t resultSize;                // The expected number of results.
+  uint64_t resultSize;              // The expected number of results.
   std::string resultTsv;            // The expected result in TSV format.
   std::string resultCsv;            // The expected result in CSV format
   nlohmann::json resultQLeverJSON;  // The expected result in QLeverJSOn format.
@@ -102,10 +105,14 @@ struct TestCaseAskQuery {
   std::string resultXml;
 };
 
+// For a CONSTRUCT query, the `resultSize` of the QLever JSON is the number of
+// results of the WHERE clause.
 struct TestCaseConstructQuery {
   std::string kg;                   // The knowledge graph (TURTLE)
   std::string query;                // The query (SPARQL)
-  size_t resultSize;                // The expected number of results.
+  uint64_t resultSizeTotal;         // The expected number of results,
+                                    // including triples with UNDEF values.
+  uint64_t resultSizeExported;      // The expected number of results exported.
   std::string resultTsv;            // The expected result in TSV format.
   std::string resultCsv;            // The expected result in CSV format
   std::string resultTurtle;         // The expected result in Turtle format
@@ -128,13 +135,14 @@ void runSelectQueryTestCase(
       runQueryStreamableResult(testCase.kg, testCase.query, csv, useTextIndex),
       testCase.resultCsv);
 
-  auto qleverJSONResult = nlohmann::json::parse(runQueryStreamableResult(
+  auto resultJSON = nlohmann::json::parse(runQueryStreamableResult(
       testCase.kg, testCase.query, qleverJson, useTextIndex));
   // TODO<joka921> Test other members of the JSON result (e.g. the selected
   // variables).
-  ASSERT_EQ(qleverJSONResult["query"], testCase.query);
-  ASSERT_EQ(qleverJSONResult["resultsize"], testCase.resultSize);
-  EXPECT_EQ(qleverJSONResult["res"], testCase.resultQLeverJSON);
+  ASSERT_EQ(resultJSON["query"], testCase.query);
+  ASSERT_EQ(resultJSON["resultSizeTotal"], testCase.resultSize);
+  ASSERT_EQ(resultJSON["resultSizeExported"], testCase.resultSize);
+  EXPECT_EQ(resultJSON["res"], testCase.resultQLeverJSON);
 
   EXPECT_EQ(nlohmann::json::parse(runQueryStreamableResult(
                 testCase.kg, testCase.query, sparqlJson, useTextIndex)),
@@ -144,6 +152,16 @@ void runSelectQueryTestCase(
   auto xmlAsString = runQueryStreamableResult(testCase.kg, testCase.query,
                                               sparqlXml, useTextIndex);
   EXPECT_EQ(testCase.resultXml, xmlAsString);
+
+  // Test the interaction of normal limit (the LIMIT of the query) and export
+  // limit (the value of the `send` parameter).
+  for (uint64_t exportLimit = 0ul; exportLimit < 4ul; ++exportLimit) {
+    auto resultJson = nlohmann::json::parse(runQueryStreamableResult(
+        testCase.kg, testCase.query, qleverJson, false, exportLimit));
+    ASSERT_EQ(resultJson["resultSizeTotal"], testCase.resultSize);
+    ASSERT_EQ(resultJson["resultSizeExported"],
+              std::min(exportLimit, testCase.resultSize));
+  }
 }
 
 // Run a single test case for a CONSTRUCT query.
@@ -156,13 +174,24 @@ void runConstructQueryTestCase(
             testCase.resultTsv);
   EXPECT_EQ(runQueryStreamableResult(testCase.kg, testCase.query, csv),
             testCase.resultCsv);
-  auto qleverJSONStreamResult = nlohmann::json::parse(
+  auto resultJson = nlohmann::json::parse(
       runQueryStreamableResult(testCase.kg, testCase.query, qleverJson));
-  ASSERT_EQ(qleverJSONStreamResult["query"], testCase.query);
-  ASSERT_EQ(qleverJSONStreamResult["resultsize"], testCase.resultSize);
-  EXPECT_EQ(qleverJSONStreamResult["res"], testCase.resultQLeverJSON);
+  ASSERT_EQ(resultJson["query"], testCase.query);
+  ASSERT_EQ(resultJson["resultSizeTotal"], testCase.resultSizeTotal);
+  ASSERT_EQ(resultJson["resultSizeExported"], testCase.resultSizeExported);
+  EXPECT_EQ(resultJson["res"], testCase.resultQLeverJSON);
   EXPECT_EQ(runQueryStreamableResult(testCase.kg, testCase.query, turtle),
             testCase.resultTurtle);
+
+  // Test the interaction of normal limit (the LIMIT of the query) and export
+  // limit (the value of the `send` parameter).
+  for (uint64_t exportLimit = 0ul; exportLimit < 4ul; ++exportLimit) {
+    auto resultJson = nlohmann::json::parse(runQueryStreamableResult(
+        testCase.kg, testCase.query, qleverJson, false, exportLimit));
+    ASSERT_EQ(resultJson["resultSizeTotal"], testCase.resultSizeTotal);
+    ASSERT_EQ(resultJson["resultSizeExported"],
+              std::min(exportLimit, testCase.resultSizeExported));
+  }
 }
 
 // Run a single test case for an ASK query.
@@ -178,11 +207,11 @@ void runAskQueryTestCase(
       runQueryStreamableResult(testCase.kg, testCase.query, octetStream));
   EXPECT_ANY_THROW(
       runQueryStreamableResult(testCase.kg, testCase.query, turtle));
-  auto qleverJSONStreamResult = nlohmann::json::parse(
+  auto resultJson = nlohmann::json::parse(
       runQueryStreamableResult(testCase.kg, testCase.query, qleverJson));
-  ASSERT_EQ(qleverJSONStreamResult["query"], testCase.query);
-  ASSERT_EQ(qleverJSONStreamResult["resultsize"], 1u);
-  EXPECT_EQ(qleverJSONStreamResult["res"], testCase.resultQLeverJSON);
+  ASSERT_EQ(resultJson["query"], testCase.query);
+  ASSERT_EQ(resultJson["resultSizeExported"], 1u);
+  EXPECT_EQ(resultJson["res"], testCase.resultQLeverJSON);
 
   EXPECT_EQ(nlohmann::json::parse(runQueryStreamableResult(
                 testCase.kg, testCase.query, sparqlJson)),
@@ -344,7 +373,7 @@ TEST(ExportQueryExecutionTrees, Integers) {
   runSelectQueryTestCase(testCase);
 
   TestCaseConstructQuery testCaseConstruct{
-      kg, "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 3,
+      kg, "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 3, 3,
       // TSV
       "
\t<p>\t-42019234865781\n"
       "
\t<p>\t42\n"
@@ -402,7 +431,7 @@ TEST(ExportQueryExecutionTrees, Bool) {
   runSelectQueryTestCase(testCase);
 
   TestCaseConstructQuery testCaseConstruct{
-      kg, "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 2,
+      kg, "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 2, 2,
       // TSV
       "
\t<p>\tfalse\n"
       "
\t<p>\ttrue\n",
@@ -444,10 +473,10 @@ TEST(ExportQueryExecutionTrees, UnusedVariable) {
       makeExpectedSparqlJSON({}), expectedXml};
   runSelectQueryTestCase(testCase);
 
-  // If we use a variable that is always unbound in a CONSTRUCT triple, then
-  // the result for this triple will be empty.
+  // The `2` is the number of results including triples with UNDEF values. The
+  // `0` is the number of results excluding such triples.
   TestCaseConstructQuery testCaseConstruct{
-      kg, "CONSTRUCT {?x ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 0,
+      kg, "CONSTRUCT {?x ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 2, 0,
       // TSV
       "",
       // CSV
@@ -502,7 +531,7 @@ TEST(ExportQueryExecutionTrees, Floats) {
   runSelectQueryTestCase(testCaseFloat);
 
   TestCaseConstructQuery testCaseConstruct{
-      kg, "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 3,
+      kg, "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o", 3, 3,
       // TSV
       "
\t<p>\t-42019234865780982022144\n"
       "
\t<p>\t4.01293e-12\n"
@@ -559,6 +588,7 @@ TEST(ExportQueryExecutionTrees, Dates) {
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t\"1950-01-01T00:00:00\"^^<http://www.w3.org/2001/"
       "XMLSchema#dateTime>\n",  // missing
@@ -648,6 +678,7 @@ TEST(ExportQueryExecutionTrees, Entities) {
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t<http://qlever.com/o>\n",
       // CSV
@@ -696,6 +727,7 @@ TEST(ExportQueryExecutionTrees, LiteralWithLanguageTag) {
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t\"Some\"Where Over,\"@en-ca\n",
       // CSV
@@ -744,6 +776,7 @@ TEST(ExportQueryExecutionTrees, LiteralWithDatatype) {
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t\"something\"^^<www.example.org/bim>\n",
       // CSV
@@ -791,6 +824,7 @@ TEST(ExportQueryExecutionTrees, LiteralPlain) {
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t\"something\"\n",
       // CSV
@@ -836,6 +870,7 @@ testIriKg</uri></binding>
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t<https:// : )\\ntestIriKg>\n",
       // CSV
@@ -899,6 +934,7 @@ TEST(ExportQueryExecutionTrees, TestWithIriExtendedEscaped) {
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t<iriescaped\x01o\x02"
       "e\x03i\x04o\x05u\x06"
@@ -953,6 +989,7 @@ TEST(ExportQueryExecutionTrees, TestIriWithEscapedIriString) {
       kg,
       "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o} ORDER BY ?o",
       1,
+      1,
       // TSV
       "
\t<p>\t\" hallo\\n  welt\"\n",
       // CSV
@@ -993,12 +1030,13 @@ TEST(ExportQueryExecutionTrees, UndefinedValues) {
       expectedXml};
   runSelectQueryTestCase(testCase);
 
-  // In CONSTRUCT queries, results with undefined values in the exported
-  // variables are filtered out, so the result is empty.
+  // The `1` is the number of results including triples with UNDEF values. The
+  // `0` is the number of results excluding such triples.
   TestCaseConstructQuery testCaseConstruct{
       kg,
       "CONSTRUCT {?s <pred> ?o} WHERE {?s <p> <o> OPTIONAL {?s <p2> ?o}} ORDER "
       "BY ?o",
+      1,
       0,
       "",
       "",
@@ -1338,12 +1376,14 @@ TEST(ExportQueryExecutionTrees, ensureCorrectSlicingOfSingleIdTable) {
   }();
 
   Result result{std::move(tableGenerator), {}};
+  uint64_t resultSizeTotal = 0;
   auto generator = ExportQueryExecutionTrees::getRowIndices(
-      LimitOffsetClause{._limit = 1, ._offset = 1}, result);
+      LimitOffsetClause{._limit = 1, ._offset = 1}, result, resultSizeTotal);
 
-  auto referenceTable = makeIdTableFromVector({{2}});
+  auto expectedResult = makeIdTableFromVector({{2}});
   EXPECT_THAT(convertToVector(std::move(generator)),
-              matchesIdTables(referenceTable));
+              matchesIdTables(expectedResult));
+  EXPECT_EQ(resultSizeTotal, 1);
 }
 
 // _____________________________________________________________________________
@@ -1360,13 +1400,16 @@ TEST(ExportQueryExecutionTrees,
   }();
 
   Result result{std::move(tableGenerator), {}};
+  uint64_t resultSizeTotal = 0;
   auto generator = ExportQueryExecutionTrees::getRowIndices(
-      LimitOffsetClause{._limit = std::nullopt, ._offset = 3}, result);
+      LimitOffsetClause{._limit = std::nullopt, ._offset = 3}, result,
+      resultSizeTotal);
 
-  auto referenceTable1 = makeIdTableFromVector({{4}, {5}});
+  auto expectedResult = makeIdTableFromVector({{4}, {5}});
 
   EXPECT_THAT(convertToVector(std::move(generator)),
-              matchesIdTables(referenceTable1));
+              matchesIdTables(expectedResult));
+  EXPECT_EQ(resultSizeTotal, 2);
 }
 
 // _____________________________________________________________________________
@@ -1383,13 +1426,15 @@ TEST(ExportQueryExecutionTrees,
   }();
 
   Result result{std::move(tableGenerator), {}};
+  uint64_t resultSizeTotal = 0;
   auto generator = ExportQueryExecutionTrees::getRowIndices(
-      LimitOffsetClause{._limit = 3}, result);
+      LimitOffsetClause{._limit = 3}, result, resultSizeTotal);
 
-  auto referenceTable1 = makeIdTableFromVector({{1}, {2}, {3}});
+  auto expectedResult = makeIdTableFromVector({{1}, {2}, {3}});
 
   EXPECT_THAT(convertToVector(std::move(generator)),
-              matchesIdTables(referenceTable1));
+              matchesIdTables(expectedResult));
+  EXPECT_EQ(resultSizeTotal, 3);
 }
 
 // _____________________________________________________________________________
@@ -1406,14 +1451,16 @@ TEST(ExportQueryExecutionTrees,
   }();
 
   Result result{std::move(tableGenerator), {}};
+  uint64_t resultSizeTotal = 0;
   auto generator = ExportQueryExecutionTrees::getRowIndices(
-      LimitOffsetClause{._limit = 3, ._offset = 1}, result);
+      LimitOffsetClause{._limit = 3, ._offset = 1}, result, resultSizeTotal);
 
-  auto referenceTable1 = makeIdTableFromVector({{2}, {3}});
-  auto referenceTable2 = makeIdTableFromVector({{4}});
+  auto expectedResult1 = makeIdTableFromVector({{2}, {3}});
+  auto expectedResult2 = makeIdTableFromVector({{4}});
 
   EXPECT_THAT(convertToVector(std::move(generator)),
-              matchesIdTables(referenceTable1, referenceTable2));
+              matchesIdTables(expectedResult1, expectedResult2));
+  EXPECT_EQ(resultSizeTotal, 3);
 }
 
 // _____________________________________________________________________________
@@ -1434,30 +1481,33 @@ TEST(ExportQueryExecutionTrees,
   }();
 
   Result result{std::move(tableGenerator), {}};
+  uint64_t resultSizeTotal = 0;
   auto generator = ExportQueryExecutionTrees::getRowIndices(
-      LimitOffsetClause{._limit = 5, ._offset = 2}, result);
+      LimitOffsetClause{._limit = 5, ._offset = 2}, result, resultSizeTotal);
 
-  auto referenceTable1 = makeIdTableFromVector({{3}});
-  auto referenceTable2 = makeIdTableFromVector({{4}, {5}});
-  auto referenceTable3 = makeIdTableFromVector({{6}, {7}});
+  auto expectedTable1 = makeIdTableFromVector({{3}});
+  auto expectedTable2 = makeIdTableFromVector({{4}, {5}});
+  auto expectedTable3 = makeIdTableFromVector({{6}, {7}});
 
-  EXPECT_THAT(
-      convertToVector(std::move(generator)),
-      matchesIdTables(referenceTable1, referenceTable2, referenceTable3));
+  EXPECT_THAT(convertToVector(std::move(generator)),
+              matchesIdTables(expectedTable1, expectedTable2, expectedTable3));
+  EXPECT_EQ(resultSizeTotal, 5);
 }
 
 // _____________________________________________________________________________
 TEST(ExportQueryExecutionTrees, ensureGeneratorIsNotConsumedWhenNotRequired) {
   {
     auto throwingGenerator = []() -> Result::Generator {
-      ADD_FAILURE() << "Generator was started" << std::endl;
-      throw std::runtime_error("Generator was started");
+      std::string message = "Generator was started, but should not have been";
+      ADD_FAILURE() << message << std::endl;
+      throw std::runtime_error(message);
       co_return;
     }();
 
     Result result{std::move(throwingGenerator), {}};
+    uint64_t resultSizeTotal = 0;
     auto generator = ExportQueryExecutionTrees::getRowIndices(
-        LimitOffsetClause{._limit = 0, ._offset = 0}, result);
+        LimitOffsetClause{._limit = 0, ._offset = 0}, result, resultSizeTotal);
     EXPECT_NO_THROW(convertToVector(std::move(generator)));
   }
 
@@ -1467,17 +1517,22 @@ TEST(ExportQueryExecutionTrees, ensureGeneratorIsNotConsumedWhenNotRequired) {
                                      LocalVocab{}};
       co_yield pair1;
 
-      ADD_FAILURE() << "Generator was resumed" << std::endl;
-      throw std::runtime_error("Generator was resumed");
+      std::string message =
+          "Generator was called a second time, but should not "
+          "have been";
+      ADD_FAILURE() << message << std::endl;
+      throw std::runtime_error(message);
     }();
 
     Result result{std::move(throwAfterYieldGenerator), {}};
+    uint64_t resultSizeTotal = 0;
     auto generator = ExportQueryExecutionTrees::getRowIndices(
-        LimitOffsetClause{._limit = 1, ._offset = 0}, result);
-    IdTable referenceTable1 = makeIdTableFromVector({{1}});
+        LimitOffsetClause{._limit = 1, ._offset = 0}, result, resultSizeTotal);
+    IdTable expectedTable = makeIdTableFromVector({{1}});
     std::vector<IdTable> tables;
     EXPECT_NO_THROW({ tables = convertToVector(std::move(generator)); });
-    EXPECT_THAT(tables, matchesIdTables(referenceTable1));
+    EXPECT_THAT(tables, matchesIdTables(expectedTable));
+    EXPECT_EQ(resultSizeTotal, 1);
   }
 }