Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/export-empty-result-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/rest": patch
---

fix(rest): export emits the projected header row on an empty result set (#3547)

`GET /data/:object/export` wrote a zero-byte file whenever the query matched no
rows — the header was only ever written alongside the first data chunk. With the
`getReadableFields` column projection the readable column set is derived from
schema + context, so it is known even when no rows come back: an empty CSV/xlsx
export now carries the exact readable header, which also makes it a usable
import template.

The header is emitted only when the column set is AUTHORITATIVE — the security
service's readable projection, or an explicit `?fields=` request. When the header
is schema-derived and the projection was unavailable, the export stays headerless
as before: the masked-row fallback has no rows to narrow with, and writing the
full schema header would name FLS-hidden columns. `header=false` still suppresses
the header in every case.
78 changes: 78 additions & 0 deletions packages/rest/src/export-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,4 +490,82 @@ describe('export route — FLS column projection via getReadableFields (#3547)',
const header = parseCsv(csv.chunks.join(''))[0];
expect(header).toEqual(['ID', '标题']); // requested columns kept as asked
});

// -------------------------------------------------------------------------
// Empty result sets. "Export columns don't depend on row content" is only
// true if it also holds at ZERO rows — the case the masked-row inference
// (#3498) could never serve, because it had no rows to narrow with.
// -------------------------------------------------------------------------

it('empty result set: still emits the projected header (an exact, row-independent column set)', async () => {
// No rows at all. The service knows the readable columns from schema +
// context, so the export carries the precise header — and an empty export
// becomes a usable import template instead of a zero-byte file.
const { route } = await bootWithSecurity({
getReadableFields: () => ['id', 'done', 'priority', 'due', 'owner'],
tasks: [],
});
const csv = makeRes();
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res);
const rows = parseCsv(csv.chunks.join(''));
expect(rows).toHaveLength(1); // header only, no data rows
expect(rows[0]).toEqual(['ID', '完成', '优先级', '截止', '负责人']);
expect(rows[0]).not.toContain('标题'); // the masked column stays out
});

it('empty result set with NO projection available: stays headerless (never names FLS-hidden columns)', async () => {
// getReadableFields → undefined (schema unresolvable, or no security service
// at all) → the route falls back to masked-row inference, which has no rows.
// Writing the full schema header HERE would leak the names of FLS-hidden
// columns — the very leak #3391 closes — so the empty file stays headerless.
const { route } = await bootWithSecurity({
getReadableFields: () => undefined,
tasks: [],
});
const csv = makeRes();
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res);
expect(csv.chunks.join('')).toBe('');
});

it('empty result set + explicit ?fields=: the requested header is echoed back', async () => {
// An explicit request is authoritative for the same reason the projection is:
// the caller named the columns, so nothing new is disclosed by echoing them.
const { route } = await bootWithSecurity({
getReadableFields: () => ['id'],
tasks: [],
});
const csv = makeRes();
await route.handler(
{ params: { object: 'task' }, query: { format: 'csv', fields: 'id,title' } } as any,
csv.res,
);
expect(parseCsv(csv.chunks.join(''))).toEqual([['ID', '标题']]);
});

it('empty result set + header=false: no header, as asked', async () => {
const { route } = await bootWithSecurity({
getReadableFields: () => ['id', 'done'],
tasks: [],
});
const csv = makeRes();
await route.handler(
{ params: { object: 'task' }, query: { format: 'csv', header: 'false' } } as any,
csv.res,
);
expect(csv.chunks.join('')).toBe('');
});

it('xlsx: an empty result set carries the projected header row', async () => {
const { route } = await bootWithSecurity({
getReadableFields: () => ['id', 'done'],
tasks: [],
});
const { res, getBuffer } = makeBinRes();
await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(getBuffer() as any);
const ws = wb.worksheets[0];
expect((ws.getRow(1).values as any[]).slice(1)).toEqual(['ID', '完成']);
expect(ws.rowCount).toBe(1); // header only
});
});
31 changes: 31 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4219,6 +4219,11 @@ export class RestServer {
// their option label, booleans to 是/否, dates to YYYY-MM-DD. When the
// schema is unavailable the raw stored values stream through unchanged.
//
// A zero-row result still emits the header row when the column set is
// authoritative (the security service's readable projection, or an explicit
// `fields=`), so an empty export doubles as an import template. Without a
// projection it stays headerless, so FLS-hidden column names never leak.
//
// Streams the response so 50k-row exports do not buffer in memory; the
// xlsx path pipes exceljs' streaming writer straight onto the response.
// Filename suggests `${objectLabel}-${YYYYMMDD}-${HHMMSS}.${ext}` for
Expand Down Expand Up @@ -4481,6 +4486,32 @@ export class RestServer {
skip += rows.length;
if (rows.length < take) break;
}
// [#3547] Zero rows: still emit the header when the column set is
// AUTHORITATIVE. "Export columns don't depend on row content" is
// only true if it also holds at zero rows — and the readable
// projection above is derived from schema + context, so an empty
// result has an exact header to write (which also makes an empty
// export a usable import template). An explicit `?fields=` is
// authoritative for the same reason: the caller named the columns.
//
// Deliberately NOT emitted when the header is schema-derived and
// the projection was unavailable (`fieldsFromSchema &&
// !readableProjected`): the masked-row fallback has no rows to
// narrow with, so writing the full schema header would name
// FLS-hidden columns — precisely the leak #3391 closes. That path
// keeps today's headerless empty file.
if (
firstChunk && includeHeader && fields && fields.length > 0 &&
(readableProjected || !fieldsFromSchema)
) {
if (format === 'csv') {
res.write(rowsToCsv(fields, [], true, metaMap));
} else if (format === 'xlsx') {
xlsx!.ws.addRow(fields.map((f) => headerLabel(f, metaMap))).commit();
}
// json has no header concept — the empty array is already correct.
}

if (format === 'json') {
res.write(']');
res.end();
Expand Down
Loading