From 881f6797e82b27c530a123b2092ef975cb836257 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:29:05 +0000 Subject: [PATCH 1/2] fix(rest)!: format=json honours an explicit ?fields= The export route declares `fields=a,b,c` for every format. CSV and XLSX wrote exactly the listed columns; the JSON arm wrote each row as the read returned it. With an explicit list, each JSON row now carries exactly the named keys, in list order, read from the same resolved `fields` the other two arms write. A named key the read did not return stays absent. format=json without ?fields= is unchanged. Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb Co-Authored-By: Claude --- .changeset/24183-export-json-fields.md | 18 ++ packages/rest/src/data-action-routes.ts | 41 +++- packages/rest/src/export-json-fields.test.ts | 192 +++++++++++++++++++ 3 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 .changeset/24183-export-json-fields.md create mode 100644 packages/rest/src/export-json-fields.test.ts diff --git a/.changeset/24183-export-json-fields.md b/.changeset/24183-export-json-fields.md new file mode 100644 index 00000000000..7c4b9b0b6a3 --- /dev/null +++ b/.changeset/24183-export-json-fields.md @@ -0,0 +1,18 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest)!: `format=json` honours an explicit `?fields=`, so each exported row carries exactly the named keys, in list order + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`GET /api/v1/data/:object/export` declares `fields=a,b,c` for every format. The CSV and XLSX arms wrote exactly the listed columns, but the JSON arm wrote each row as the read returned it, so `?format=json&fields=…` was ignored without any sign. objectui's list-view and grid export send `format=json` with the view's visible columns, so a JSON export of a list view carried every readable field instead of the view's columns. + +- **Served less now:** with `?fields=`, each JSON row carries exactly the named keys, in the order the list names them, with the same readable formatting as before. Measured on a dev boot of `examples/app-showcase`: `?format=json&fields=title` on `showcase_task` answered 25 keys per row (`id`, the audit and ownership columns, `organization_id`, `owning_business_unit_id` and every author field) and now answers `title` alone. A named key the read did not return (a field that field-level security removes from the row, or the `__search` companion the read strips) is absent from the JSON row. It is not written as `null`. The CSV and XLSX arms write an empty cell for it, as before. +- **Unchanged:** `format=json` without `?fields=` still writes each row as the read returns it. The CSV and XLSX arms, their default columns and their header rules are untouched. + +What changes for you: a caller that sends `format=json` with `fields` and reads a key it did not name, such as `id`, adds that key to `fields`. diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 7fe336246ce..e78162e654a 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -156,6 +156,24 @@ import { */ export type ServerScopedDataRequest = R & { environmentId?: string; context?: unknown }; +/** + * [#24183] One export row as a json object carrying exactly `fields`, in that + * order — the json arm's counterpart of the column list `formatRowCells` writes + * for csv / xlsx. A name whose value the read did not return is left out, never + * written as `null`: the read answered nothing for it, and inventing a value + * would read as a stored null. + */ +function pickExportKeys(row: Record, fields: readonly string[]): Record { + const out: Record = {}; + if (!row || typeof row !== 'object') return out; + for (const f of fields) { + // Own keys only: a name such as `constructor` must not pick up a + // prototype member the row never carried. + if (Object.prototype.hasOwnProperty.call(row, f) && row[f] !== undefined) out[f] = row[f]; + } + return out; +} + /** * The `RestServer` members {@link registerDataActionEndpoints} reads — every * one of them, and no other. Each is typed AS the class member (an indexed @@ -749,8 +767,12 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: // // Query params: // format=csv|json|xlsx (default: csv. json emits a JSON array, xlsx a workbook.) - // fields=a,b,c (default: the object schema's fields minus those declared - // `hidden`; falls back to keys of the first row) + // fields=a,b,c (every format. csv / xlsx write exactly these columns and + // json rows carry exactly these keys, in list order; a key + // the read did not return is absent from a json row. + // Default for csv / xlsx: the object schema's fields minus + // those declared `hidden`; falls back to keys of the first + // row. Without it json writes each row as the read returns it.) // filter= ($filter as URL-encoded JSON, same shape as list endpoint) // search= (full-text term, same semantics as the list endpoint's // $search; composes with `filter` rather than replacing it) @@ -958,6 +980,10 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } else if (Array.isArray(q.fields)) { fields = q.fields.filter((s: any) => typeof s === 'string' && s.length > 0); } + // [#24183] The caller named the columns. Nothing below narrows + // or reorders an explicit list, so `fields` stays exactly this + // for every format, json included. + const fieldsExplicit = !!fields && fields.length > 0; // Field metadata drives readable formatting (lookup names, select // labels, 是/否, formatted dates) and the $expand that resolves @@ -1173,9 +1199,18 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: r.commit(); } } else { + // [#24183] An explicit `?fields=` is json's column set as + // it is csv's and xlsx's: each row carries exactly the + // named keys, in list order, read from the same resolved + // `fields` the other two arms write. A named key the read + // did not return (field-level security deletes it, the + // read strips the search companion) stays absent rather + // than being invented as `null` — the answer the list + // door gives the same `$select`. for (let i = 0; i < rows.length; i++) { const prefix = (firstChunk && i === 0) ? '' : ','; - res.write(prefix + JSON.stringify(formatRowForJson(rows[i], metaMap, timezone))); + const row = fieldsExplicit ? pickExportKeys(rows[i], fields!) : rows[i]; + res.write(prefix + JSON.stringify(formatRowForJson(row, metaMap, timezone))); } } firstChunk = false; diff --git a/packages/rest/src/export-json-fields.test.ts b/packages/rest/src/export-json-fields.test.ts new file mode 100644 index 00000000000..7528c9e06fa --- /dev/null +++ b/packages/rest/src/export-json-fields.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24183] `format=json` honours an explicit `?fields=` list. + * + * The route's doc comment declares `fields=a,b,c` for every format. The CSV + * and XLSX arms write exactly the listed columns, in list order; the JSON arm + * wrote each row as the read returned it, so objectui's list-view export — + * which sends `format=json` with the view's visible columns — downloaded every + * readable field, and no status told the caller its `fields` was ignored. + * + * Driven through the REAL export route: a real `RestServer` on a real + * `ObjectStackProtocolImplementation` on a real `ObjectQL` with a better-sqlite3 + * `:memory:` driver. The protocol's `findData` is observed, so every pin can + * show what the read handed the route next to what the route answered — the + * projection is the route's, not the read's. + * + * What is pinned: + * - the CSV arm, measured: `?fields=title` writes the `Title` column and no + * other, so the CSV arm adds no always-written key and JSON adds none; + * - JSON rows carry exactly the named keys, in list order, on every chunk; + * - values keep the export's readable formatting; + * - a named key the read did not return (the stripped `__search` companion) + * is absent from the row, never invented as `null` — the same answer the + * list door's `$select` gives; + * - the projection does not depend on a security service being composed. + * + * Out of scope here: `format=json` with no `?fields=` — its column set is a + * separate decision (the export's schema-derived default does not carry `id` + * on an object that does not declare it). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL, provisionSearchCompanion } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; +import { RestServer } from './rest-server'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** + * `title` is the name field, so the companion's real producer declares + * `__search` on this object (and the read strips its value). `done` is a + * boolean, so a projected value is visibly formatted (`是` / `否`). + */ +const NOTE = provisionSearchCompanion({ + name: 'note', + label: 'Note', + nameField: 'title', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + title: { name: 'title', type: 'text' as const, label: 'Title' }, + internal_code: { name: 'internal_code', type: 'text' as const, label: 'Internal Code', hidden: true }, + done: { name: 'done', type: 'boolean' as const, label: 'Done' }, + }, +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const chunks: string[] = []; + const res: any = { + write: (s: string) => { chunks.push(typeof s === 'string' ? s : String(s)); return true; }, + end: () => {}, + header: () => res, + status: () => res, + json: () => res, + }; + return { res, text: () => chunks.join('') }; +} + +async function boot(opts: { withSecurity: boolean; rowCount?: number }) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await engine.init(); + engine.registry.registerObject(NOTE as any); + await engine.syncSchemas(); + const rowCount = opts.rowCount ?? 2; + for (let i = 1; i <= rowCount; i++) { + await engine.insert('note', { id: String(i), title: `Note ${i}`, internal_code: `IC-${i}`, done: i % 2 === 1 }); + } + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } + const protocol = new ObjectStackProtocolImplementation(engine as any); + // Every row the route reads, as the read handed it over, and how many reads. + const readRows: Array> = []; + const reads = { count: 0 }; + const findData = protocol.findData.bind(protocol); + (protocol as any).findData = async (args: any) => { + const result: any = await findData(args); + if (args?.object === 'note') { + reads.count += 1; + for (const r of result?.records ?? []) readRows.push({ ...r }); + } + return result; + }; + // plugin-security's answer for a caller no field-level rule restricts. + const getReadableFields = (object: string) => Object.keys((engine.registry.getObject(object) as any)?.fields ?? {}); + const securityServiceProvider = opts.withSecurity ? async () => ({ getReadableFields }) : undefined; + const rest = new RestServer( + createMockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + securityServiceProvider, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + )!; + return { route, readRows, reads }; +} + +async function exportOf(route: any, query: Record): Promise { + const { res, text } = makeRes(); + await route.handler({ params: { object: 'note' }, query } as any, res); + return text(); +} + +async function jsonOf(route: any, query: Record): Promise>> { + return JSON.parse(await exportOf(route, { format: 'json', ...query })); +} + +describe('format=json honours an explicit ?fields= (#24183)', () => { + it('the CSV arm, measured: `?fields=title` writes that one column and no always-written key', async () => { + const { route, readRows } = await boot({ withSecurity: true }); + const lines = (await exportOf(route, { format: 'csv', fields: 'title' })).split('\r\n').filter(Boolean); + expect(lines).toEqual(['Title', 'Note 1', 'Note 2']); + // The read handed the route more than the one column it wrote. + expect(Object.keys(readRows[0])).toEqual(expect.arrayContaining(['id', 'title', 'internal_code', 'done'])); + }); + + it('JSON `?fields=title`: each row carries exactly `title`, though the read returned more', async () => { + const { route, readRows } = await boot({ withSecurity: true }); + const rows = await jsonOf(route, { fields: 'title' }); + expect(rows).toEqual([{ title: 'Note 1' }, { title: 'Note 2' }]); + expect(readRows).toHaveLength(2); + expect(Object.keys(readRows[0])).toEqual(expect.arrayContaining(['id', 'title', 'internal_code', 'done'])); + }); + + it('JSON: an explicit list keeps its order, and the values keep the export formatting', async () => { + const { route } = await boot({ withSecurity: true }); + const rows = await jsonOf(route, { fields: 'done,internal_code,title,id' }); + expect(rows.map((r) => Object.keys(r))).toEqual([ + ['done', 'internal_code', 'title', 'id'], + ['done', 'internal_code', 'title', 'id'], + ]); + expect(rows[0]).toEqual({ done: '是', internal_code: 'IC-1', title: 'Note 1', id: '1' }); + expect(rows[1]).toEqual({ done: '否', internal_code: 'IC-2', title: 'Note 2', id: '2' }); + }); + + it('JSON: a named key the read did not return is absent, never invented as null (the stripped companion)', async () => { + const { route, readRows } = await boot({ withSecurity: true }); + const rows = await jsonOf(route, { fields: 'title,__search' }); + expect(readRows.every((r) => !('__search' in r))).toBe(true); + expect(rows).toEqual([{ title: 'Note 1' }, { title: 'Note 2' }]); + // The CSV arm answers the same list with an empty column. + const lines = (await exportOf(route, { format: 'csv', fields: 'title,__search' })).split('\r\n').filter(Boolean); + expect(lines).toEqual(['Title,Search Index', 'Note 1,', 'Note 2,']); + }); + + it('JSON: the projection holds on every chunk, not only the first', async () => { + const { route, readRows, reads } = await boot({ withSecurity: true, rowCount: 60 }); + // `page` is the chunk size; its floor is 50, so 60 rows read in two chunks. + const rows = await jsonOf(route, { fields: 'id', page: '50' }); + expect(reads.count).toBe(2); + expect(readRows).toHaveLength(60); + expect(rows).toHaveLength(60); + expect(rows.every((r) => Object.keys(r).length === 1 && typeof r.id === 'string')).toBe(true); + expect(rows[59]).toEqual({ id: expect.any(String) }); + }); + + it('JSON: the projection does not depend on a security service being composed', async () => { + const { route } = await boot({ withSecurity: false }); + const rows = await jsonOf(route, { fields: 'title,id' }); + expect(rows).toEqual([{ title: 'Note 1', id: '1' }, { title: 'Note 2', id: '2' }]); + }); +}); From 939c8ac1a7fa3f14259029ef662f6d82c2f4ad5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:07:12 +0000 Subject: [PATCH 2/2] test(rest): pin format=json without ?fields= to the read's own row The seat decided the no-fields fork as D: `QuerySchema.fields` absent means no projection, so json without `fields` writes each row as the read returns it, `id` included. One pin through the real route asserts each row's key set equals the observed read's, and that the read carries a key the csv default drops, so a cut to that default reddens it. Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb Co-Authored-By: Claude --- packages/rest/src/export-json-fields.test.ts | 28 ++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/rest/src/export-json-fields.test.ts b/packages/rest/src/export-json-fields.test.ts index 7528c9e06fa..6d4d7602d42 100644 --- a/packages/rest/src/export-json-fields.test.ts +++ b/packages/rest/src/export-json-fields.test.ts @@ -23,11 +23,13 @@ * - a named key the read did not return (the stripped `__search` companion) * is absent from the row, never invented as `null` — the same answer the * list door's `$select` gives; - * - the projection does not depend on a security service being composed. - * - * Out of scope here: `format=json` with no `?fields=` — its column set is a - * separate decision (the export's schema-derived default does not carry `id` - * on an object that does not declare it). + * - the projection does not depend on a security service being composed; + * - with NO `?fields=`, json writes each row as the read returns it: no + * projection, `id` included. `QuerySchema.fields` ("Fields to retrieve", + * optional) absent means no projection. The csv / xlsx default (schema + * fields minus `hidden`) is a tabular default and is not json's: on an + * object that declares no `id` it carries none, and json rows cut to it + * re-import as duplicates when upserted on `id`. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -189,4 +191,20 @@ describe('format=json honours an explicit ?fields= (#24183)', () => { const rows = await jsonOf(route, { fields: 'title,id' }); expect(rows).toEqual([{ title: 'Note 1', id: '1' }, { title: 'Note 2', id: '2' }]); }); + + it('JSON with NO `?fields=`: each row carries the read\'s own keys, `id` included — not the csv default', async () => { + const { route, readRows } = await boot({ withSecurity: true }); + const rows = await jsonOf(route, {}); + expect(readRows).toHaveLength(2); + expect(rows).toHaveLength(2); + rows.forEach((row, i) => { + expect(row.id).toBe(String(i + 1)); + expect(Object.keys(row)).toEqual(Object.keys(readRows[i])); + }); + // Not vacuous: the read returned a key the csv default drops (the hidden + // `internal_code`), so a no-fields json cut to that default reddens here. + expect(Object.keys(readRows[0])).toContain('internal_code'); + const csvHeader = (await exportOf(route, { format: 'csv' })).split('\r\n')[0]; + expect(csvHeader.split(',')).not.toContain('Internal Code'); + }); });