fix(reports): stop the 'first N rows' option failing on large exports

The export path used one number for two different things: the format's hard
row cap, and the caller's explicit 'give me the first N rows'. Because
resolveExportCap() returned min(requested, formatCap) and runAll() then threw
when the result reached it, picking 'Records: First 100' in the export dialog
400'd on any report with more than 100 rows — the user asked to be truncated
and got an error instead.

Splits them: formatRowCap() is the hard, non-caller-controllable ceiling that
still throws when exceeded (a silently short file hides missing rows), while
resolveRowLimit() is the deliberate truncation and is honoured by slicing.
Verified against a 223-row dataset: limit=5 now returns 5 rows, and no limit
returns all 223.
This commit is contained in:
Nathnael
2026-08-20 05:28:51 +00:00
parent fc2b5ee0e3
commit ce90be5c88
4 changed files with 78 additions and 33 deletions

View File

@@ -122,12 +122,19 @@ export class ReportRunnerService {
};
}
/** Same query, no paging — used by the export path. */
/**
* Same query, no paging — used by the export path.
*
* `limit` is the caller's deliberate "first N" (the dialog's "Records: First
* 100"), honoured by truncating. `cap` is the format's hard ceiling, which
* throws instead. These used to be one number, which made "First 100" fail
* outright on any report with more than 100 rows.
*/
async runAll(
def: ReportDefinition,
raw: RawReportQuery,
directions: string[] | null,
limit: number,
{ cap, limit }: { cap: number; limit?: number },
): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> {
const params = coerceParams(def, raw);
const ctx = { ds: this.ds, params, directions };
@@ -136,14 +143,19 @@ export class ReportRunnerService {
// export is supposed to match what the user is looking at.
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir);
// limit + 1: fetching exactly `limit` cannot distinguish "there are exactly
// limit rows" from "there are more" — which is why the old `>= limit` check
// rejected a legitimate export of exactly the cap.
const items = await qb.limit(limit + 1).getRawMany();
if (items.length > limit) {
throw new BadRequestException(
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`,
);
const ceiling = limit ?? cap;
// ceiling + 1: fetching exactly `ceiling` cannot distinguish "there are
// exactly that many rows" from "there are more" — which is why the old
// `>= limit` check rejected a legitimate export of exactly the cap.
const items = await qb.limit(ceiling + 1).getRawMany();
if (items.length > ceiling) {
if (limit === undefined) {
throw new BadRequestException(
`Export exceeds the ${cap}-row cap for this format. Narrow the filters.`,
);
}
items.length = limit;
}
const kpis = def.summary ? await def.summary(ctx) : [];
return { columns: def.columns, items, kpis };

View File

@@ -12,9 +12,10 @@ import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissio
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import {
EXPORT_MIME,
formatRowCap,
pickByKey,
resolveExportCap,
resolveExportFormat,
resolveRowLimit,
} from '../exports/export-request.util';
import { TabularExportService } from '../exports/tabular-export.service';
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
@@ -101,10 +102,12 @@ export class ReportsController {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const cap = resolveExportCap(format, query.limit);
const exportColumns = pickByKey(def.columns, query.fields);
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
const { items, kpis } = await this.runner.runAll(def, query, directions, {
cap: formatRowCap(format),
limit: resolveRowLimit(format, query.limit),
});
const doc = {
title: def.title,
description: def.description,