From ea758cbdcc3ebb16e725b05c8d88b933abded33b Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Wed, 6 May 2026 13:00:06 +0300 Subject: [PATCH 1/3] feat: add optional columns property to resource data request schema and implement column validation in AdminForthRestAPI --- adminforth/dataConnectors/baseConnector.ts | 11 ++-- adminforth/dataConnectors/clickhouse.ts | 7 +-- adminforth/dataConnectors/mongo.ts | 9 +++- adminforth/dataConnectors/mysql.ts | 6 +-- adminforth/dataConnectors/postgres.ts | 6 +-- adminforth/dataConnectors/sqlite.ts | 6 +-- adminforth/modules/restApi.ts | 63 ++++++++++++++++++++-- adminforth/types/Back.ts | 6 ++- 8 files changed, 91 insertions(+), 23 deletions(-) diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 940750ae0..bae636448 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -261,12 +261,13 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon } } - getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: { + getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: IAdminForthSort[], filters: IAdminForthAndOrFilter, + columns?: AdminForthResourceColumn[], }): Promise { throw new Error('Method not implemented.'); } @@ -595,13 +596,14 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon throw new Error('Method not implemented.'); } - async getData({ resource, limit, offset, sort, filters, getTotals }: { + async getData({ resource, limit, offset, sort, filters, getTotals, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: { field: string, direction: AdminForthSortDirections }[], filters: IAdminForthAndOrFilter, getTotals: boolean, + columns?: AdminForthResourceColumn[], }): Promise<{ data: any[], total: number }> { let normalizedFilters = filters; @@ -613,7 +615,8 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon normalizedFilters = filterValidation.normalizedFilters as IAdminForthAndOrFilter; } - const promises: Promise[] = [this.getDataWithOriginalTypes({ resource, limit, offset, sort, filters: normalizedFilters })]; + const dataSourceColumns = columns ?? resource.dataSourceColumns; + const promises: Promise[] = [this.getDataWithOriginalTypes({ resource, limit, offset, sort, filters: normalizedFilters, columns: dataSourceColumns })]; if (getTotals) { promises.push(this.getCount({ resource, filters })); } else { @@ -624,7 +627,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon // call getFieldValue for each field data.map((record) => { - for (const col of resource.dataSourceColumns) { + for (const col of dataSourceColumns) { record[col.name] = this.getFieldValue(col, record[col.name]); } }); diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index 5c21cd783..d14c4a1d8 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -510,14 +510,15 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth })); } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: { + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: { field: string, direction: AdminForthSortDirections }[], filters: IAdminForthAndOrFilter, + columns?: AdminForthResourceColumn[], }): Promise> { - const columns = resource.dataSourceColumns.map((col) => { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col) => { // for decimal cast to string if (col.type == AdminForthDataTypes.DECIMAL) { return `toString(${col.name}) as ${col.name}` @@ -532,7 +533,7 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - const q = `SELECT ${columns} FROM ${tableName} ${where} ${orderBy} LIMIT {limit:Int} OFFSET {offset:Int}`; + const q = `SELECT ${selectedColumns} FROM ${tableName} ${where} ${orderBy} LIMIT {limit:Int} OFFSET {offset:Int}`; const d = { ...params, limit, diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index ef2b106cd..602608c50 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -408,13 +408,14 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS }); } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: { field: string, direction: AdminForthSortDirections }[], filters: IAdminForthAndOrFilter, + columns?: Array<{ name: string }>, } ): Promise { @@ -429,7 +430,11 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS return [s.field, this.SortDirectionsMap[s.direction]]; }); - const result = await collection.find(query) + const projection = columns + ? Object.fromEntries(columns.map((col) => [col.name, 1])) + : undefined; + + const result = await collection.find(query, projection ? { projection } : undefined) .sort(sortArray) .skip(offset) .limit(limit) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 63bca6e65..81aa794d2 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -453,14 +453,14 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS return rows; } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise { - const columns = resource.dataSourceColumns.map((col: { name: string }) => `${col.name}`).join(', '); + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }): Promise { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col: { name: string }) => `${col.name}`).join(', '); const tableName = resource.table; const { sql: where, values: filterValues } = this.whereClauseAndValues(filters); const orderBy = sort.length ? `ORDER BY ${sort.map((s: { field: string; direction: AdminForthSortDirections }) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - let selectQuery = `SELECT ${columns} FROM ${tableName}`; + let selectQuery = `SELECT ${selectedColumns} FROM ${tableName}`; if (where) selectQuery += ` ${where}`; if (orderBy) selectQuery += ` ${orderBy}`; if (limit) selectQuery += ` LIMIT ${limit}`; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index f91f38cc6..98f5f1c69 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -428,8 +428,8 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return stmt.rows; } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise { - const columns = resource.dataSourceColumns.map((col) => `"${col.name}"`).join(', '); + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }): Promise { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col) => `"${col.name}"`).join(', '); const tableName = resource.table; const { sql: where, paramsCount, values: filterValues } = this.whereClauseAndValues(resource, filters); @@ -437,7 +437,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa const limitOffset = `LIMIT $${paramsCount} OFFSET $${paramsCount + 1}`; const d = [...filterValues, limit, offset]; const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `"${s.field}" ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - const selectQuery = `SELECT ${columns} FROM "${tableName}" ${where} ${orderBy} ${limitOffset}`; + const selectQuery = `SELECT ${selectedColumns} FROM "${tableName}" ${where} ${orderBy} ${limitOffset}`; dbLogger.trace(`🪲📜 PG Q: ${selectQuery}, params: ${JSON.stringify(d)}`); const stmt = await this.client.query(selectQuery, d); const rows = stmt.rows; diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 3f0e330b7..496d7ef0f 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -430,8 +430,8 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData return results.sort((a, b) => a.group.localeCompare(b.group)); } - async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise { - const columns = resource.dataSourceColumns.map((col) => col.name).join(', '); + async getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }): Promise { + const selectedColumns = (columns ?? resource.dataSourceColumns).map((col) => col.name).join(', '); const tableName = resource.table; const where = this.whereClause(filters); @@ -440,7 +440,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : ''; - const q = `SELECT ${columns} FROM ${tableName} ${where} ${orderBy} LIMIT ? OFFSET ?`; + const q = `SELECT ${selectedColumns} FROM ${tableName} ${where} ${orderBy} LIMIT ? OFFSET ?`; const stmt = this.client.prepare(q); const d = [...filterValues, limit, offset]; diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 7c3d7dae5..751edcefd 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -272,6 +272,13 @@ const getResourceDataRequestSchema: AnySchemaObject = { }, sort: commonSortSchema, filters: commonFiltersSchema, + columns: { + type: 'array', + description: 'Optional list of resource column names to include in returned rows. Omit it to return all visible columns.', + minItems: 1, + uniqueItems: true, + items: { type: 'string' }, + }, }, additionalProperties: true, allOf: [ @@ -1296,6 +1303,41 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } const { limit, offset, filters, sort } = body; + const selectedColumnNames = body.columns + ? [...new Set(body.columns as string[])] + : undefined; + + if (selectedColumnNames) { + const resourceColumnNames = new Set(resource.columns.map((col) => col.name)); + const invalidColumnName = selectedColumnNames.find((columnName) => !resourceColumnNames.has(columnName)); + + if (invalidColumnName) { + return { error: `Column ${invalidColumnName} not found in resource ${resourceId}` }; + } + } + const selectedColumnNameSet = selectedColumnNames ? new Set(selectedColumnNames) : undefined; + const selectedDataSourceColumnNameSet = selectedColumnNames + ? new Set(selectedColumnNames.filter((columnName) => resource.dataSourceColumns.some((col) => col.name === columnName))) + : undefined; + + if (selectedDataSourceColumnNameSet) { + for (const col of resource.columns) { + if ( + selectedColumnNameSet.has(col.name) && + col.foreignResource?.polymorphicOn + ) { + selectedDataSourceColumnNameSet.add(col.foreignResource.polymorphicOn); + } + } + } + + const selectedDataSourceColumns = selectedDataSourceColumnNameSet + ? ( + resource.dataSourceColumns.some((col) => selectedDataSourceColumnNameSet.has(col.name)) + ? resource.dataSourceColumns.filter((col) => selectedDataSourceColumnNameSet.has(col.name)) + : resource.dataSourceColumns.filter((col) => col.primaryKey || col.name === resource.dataSourceColumns[0]?.name) + ) + : undefined; // remove virtual fields from sort if still presented after beforeDatasourceRequest hook const sortFiltered = sort.filter((sortItem: IAdminForthSort) => { @@ -1334,11 +1376,14 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { filters: normalizedFilters as IAdminForthAndOrFilter, sort: sortFiltered, getTotals: source === 'list', + columns: selectedDataSourceColumns, }); // for foreign keys, add references await Promise.all( - resource.columns.filter((col) => col.foreignResource).map(async (col) => { + resource.columns.filter((col) => ( + col.foreignResource && (!selectedColumnNameSet || selectedColumnNameSet.has(col.name)) + )).map(async (col) => { let targetDataMap = {}; if (col.foreignResource.resourceId) { @@ -1471,10 +1516,12 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { delete item[key]; } } - item._label = resource.recordLabel(item); + if (!selectedColumnNameSet) { + item._label = resource.recordLabel(item); + } } } - if (source === 'list' && resource.options.listTableClickUrl) { + if (!selectedColumnNameSet && source === 'list' && resource.options.listTableClickUrl) { await Promise.all( data.data.map(async (item) => { item._clickUrl = await resource.options.listTableClickUrl(item, adminUser, resource); @@ -1501,6 +1548,16 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } + if (selectedColumnNameSet) { + for (const item of data.data) { + for (const key of Object.keys(item)) { + if (!selectedColumnNameSet.has(key)) { + delete item[key]; + } + } + } + } + return data; }, }); diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index c4fb15d72..78a480c35 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -334,12 +334,13 @@ export interface IAdminForthDataSourceConnector { * * Fields are returned from db "as is" then {@link AdminForthBaseConnector.getData} will transform each field using {@link IAdminForthDataSourceConnector.getFieldValue} */ - getDataWithOriginalTypes({ resource, limit, offset, sort, filters }: { + getDataWithOriginalTypes({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: IAdminForthSort[], filters: IAdminForthAndOrFilter, + columns?: AdminForthResourceColumn[], }): Promise>; /** @@ -397,13 +398,14 @@ export interface IAdminForthDataSourceConnectorBase extends IAdminForthDataSourc getPrimaryKey(resource: AdminForthResource): string; - getData({ resource, limit, offset, sort, filters }: { + getData({ resource, limit, offset, sort, filters, columns }: { resource: AdminForthResource, limit: number, offset: number, sort: IAdminForthSort[], filters: IAdminForthAndOrFilter, getTotals?: boolean, + columns?: AdminForthResourceColumn[], }): Promise<{ data: Array, total: number }>; getRecordByPrimaryKey(resource: AdminForthResource, recordId: string): Promise; From 8e25924c6d46085f55509e26c6ab636ff2b925b6 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Wed, 6 May 2026 14:12:39 +0300 Subject: [PATCH 2/3] feat: enhance resource data handling by updating column descriptions, adding polymorphic car references, and expanding test coverage for resource data retrieval --- adminforth/modules/restApi.ts | 2 +- tests/application/index.ts | 2 + tests/application/resources/adminuser.ts | 5 +- .../resources/polymorphic_car_refs.ts | 55 ++++++ tests/jest_tests/CRUD_sqlite.test.ts | 176 ++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 tests/application/resources/polymorphic_car_refs.ts diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 751edcefd..4ab08ffb9 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -274,7 +274,7 @@ const getResourceDataRequestSchema: AnySchemaObject = { filters: commonFiltersSchema, columns: { type: 'array', - description: 'Optional list of resource column names to include in returned rows. Omit it to return all visible columns.', + description: 'Optional list of resource column names to include in returned rows. When set, the response is projected to exactly these resource columns and computed helper fields such as _label and _clickUrl are omitted.', minItems: 1, uniqueItems: true, items: { type: 'string' }, diff --git a/tests/application/index.ts b/tests/application/index.ts index fcab42bdd..415a3a98b 100644 --- a/tests/application/index.ts +++ b/tests/application/index.ts @@ -20,6 +20,7 @@ import cars_sl_dont_allow_delete_by_hook from './resources/cars_sl_dont_allow_de import carsDescriptionImage from '../../dev-demo/resources/cars_description_image.js'; import passkeysResource from '../../dev-demo/resources/passkeys.js'; +import polymorphicCarRefs from './resources/polymorphic_car_refs.js'; const ADMIN_BASE_URL = ''; const appFilePath = fileURLToPath(import.meta.url); @@ -95,6 +96,7 @@ export const admin = new AdminForth({ cars_sl_dont_allow_delete_by_hook, carsDescriptionImage, + polymorphicCarRefs, passkeysResource ], menu: [ diff --git a/tests/application/resources/adminuser.ts b/tests/application/resources/adminuser.ts index 208be6ddc..f7d7c67f2 100644 --- a/tests/application/resources/adminuser.ts +++ b/tests/application/resources/adminuser.ts @@ -3,6 +3,9 @@ import usersResource from "../../../dev-demo/resources/adminuser.js"; export default { ...usersResource, plugins: [ - ...usersResource.plugins?.filter(p => p.className !== 'TwoFactorsAuthPlugin') || [], + ...usersResource.plugins?.filter((p) => ![ + 'AdminForthAgentPlugin', + 'TwoFactorsAuthPlugin', + ].includes(p.className)) || [], ], } \ No newline at end of file diff --git a/tests/application/resources/polymorphic_car_refs.ts b/tests/application/resources/polymorphic_car_refs.ts new file mode 100644 index 000000000..137681d97 --- /dev/null +++ b/tests/application/resources/polymorphic_car_refs.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'crypto'; +import { AdminForthDataTypes, type AdminForthResourceInput } from 'adminforth'; + +export default { + dataSource: 'sqlite', + table: 'cars_description_image', + resourceId: 'polymorphic_car_refs', + label: 'Polymorphic car refs', + columns: [ + { + name: 'id', + primaryKey: true, + required: false, + fillOnCreate: () => randomUUID(), + showIn: { + create: false, + }, + }, + { + name: 'created_at', + required: false, + fillOnCreate: () => new Date().toISOString(), + showIn: { + create: false, + }, + }, + { + name: 'resource_id', + type: AdminForthDataTypes.STRING, + required: false, + showIn: { + create: false, + edit: false, + }, + }, + { + name: 'record_id', + type: AdminForthDataTypes.STRING, + required: false, + foreignResource: { + polymorphicOn: 'resource_id', + polymorphicResources: [ + { + resourceId: 'cars_sl', + whenValue: 'car', + }, + ], + }, + }, + { + name: 'image_path', + required: false, + }, + ], +} as AdminForthResourceInput; diff --git a/tests/jest_tests/CRUD_sqlite.test.ts b/tests/jest_tests/CRUD_sqlite.test.ts index 35e4d1185..faee622ff 100644 --- a/tests/jest_tests/CRUD_sqlite.test.ts +++ b/tests/jest_tests/CRUD_sqlite.test.ts @@ -714,6 +714,182 @@ describe('POST /get_resource_data', () => { }); }); + it('keeps computed helper fields when columns are not requested', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createdRecordId }], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]._label).toBe('🚘 Abobus amogus 🚗'); + }); + + it('returns exactly requested columns and omits computed helper fields', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createdRecordId }], + columns: ['model', 'price'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toEqual({ + model: 'Abobus amogus', + price: 1234, + }); + expect(res.body.data[0]._label).toBeUndefined(); + expect(res.body.data[0]._clickUrl).toBeUndefined(); + }); + + it('returns an error for unknown requested columns', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [], + columns: ['missing_column'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBe('Column missing_column not found in resource cars_sl'); + }); + + it('supports selecting only a virtual column without leaking internal fallback columns', async () => { + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'adminuser', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'email', operator: 'eq', value: 'adminforth' }], + columns: ['password'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toEqual({}); + }); + + it('projects requested foreign columns after reference post-processing', async () => { + const adminUserRes = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'adminuser', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'email', operator: 'eq', value: 'adminforth' }], + columns: ['id'], + }); + + expect(adminUserRes.status).toEqual(200); + expect(adminUserRes.body.error).toBeUndefined(); + const adminUserId = adminUserRes.body.data[0].id; + + const updateRes = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/update_record') + .send({ + resourceId: 'cars_sl', + recordId: createdRecordId, + meta: {}, + record: { + seller_id: adminUserId, + }, + }); + + expect(updateRes.status).toEqual(200); + expect(updateRes.body.error).toBeUndefined(); + + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'cars_sl', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createdRecordId }], + columns: ['seller_id'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toEqual({ + seller_id: { + label: '👤 adminforth', + pk: adminUserId, + }, + }); + }); + + it('projects polymorphic foreign columns without leaking polymorphic discriminator columns', async () => { + const createRes = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/create_record') + .send({ + resourceId: 'polymorphic_car_refs', + record: { + record_id: createdRecordId, + image_path: 'test-image.png', + }, + requiredColumnsToSkip: [], + meta: {}, + }); + + expect(createRes.status).toEqual(200); + expect(createRes.body.error).toBeUndefined(); + + const res = await agent + .set('Cookie', authCookie) + .post('/adminapi/v1/get_resource_data') + .send({ + resourceId: 'polymorphic_car_refs', + source: 'list', + limit: 1, + offset: 0, + sort: [], + filters: [{ field: 'id', operator: 'eq', value: createRes.body.newRecordId }], + columns: ['record_id'], + }); + + expect(res.status).toEqual(200); + expect(res.body.error).toBeUndefined(); + expect(res.body.data[0]).toEqual({ + record_id: { + label: '🚘 Abobus amogus 🚗', + pk: createdRecordId, + }, + }); + expect(res.body.data[0].resource_id).toBeUndefined(); + }); + describe('POST /get_resource', () => { beforeAll(async () => { const res = await agent From ad83afcc7cab1f1430db501f36f4220452e6f80e Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Wed, 6 May 2026 15:16:05 +0300 Subject: [PATCH 3/3] feat: update resource data handling to include computed list helper fields and enhance test coverage for resource data retrieval --- adminforth/modules/restApi.ts | 14 +++++++------- .../resources/cars_sl_allow_create.ts | 6 +++++- tests/jest_tests/CRUD_sqlite.test.ts | 19 ++++++++++++------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 4ab08ffb9..6405f122d 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -274,7 +274,7 @@ const getResourceDataRequestSchema: AnySchemaObject = { filters: commonFiltersSchema, columns: { type: 'array', - description: 'Optional list of resource column names to include in returned rows. When set, the response is projected to exactly these resource columns and computed helper fields such as _label and _clickUrl are omitted.', + description: 'Optional list of resource column names to include in returned rows. For list requests, computed row helper fields such as _label and _clickUrl are still returned when available.', minItems: 1, uniqueItems: true, items: { type: 'string' }, @@ -1316,6 +1316,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } const selectedColumnNameSet = selectedColumnNames ? new Set(selectedColumnNames) : undefined; + const shouldAddListHelpers = source === 'list'; const selectedDataSourceColumnNameSet = selectedColumnNames ? new Set(selectedColumnNames.filter((columnName) => resource.dataSourceColumns.some((col) => col.name === columnName))) : undefined; @@ -1331,7 +1332,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } - const selectedDataSourceColumns = selectedDataSourceColumnNameSet + const selectedDataSourceColumns = selectedDataSourceColumnNameSet && !shouldAddListHelpers ? ( resource.dataSourceColumns.some((col) => selectedDataSourceColumnNameSet.has(col.name)) ? resource.dataSourceColumns.filter((col) => selectedDataSourceColumnNameSet.has(col.name)) @@ -1382,7 +1383,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { // for foreign keys, add references await Promise.all( resource.columns.filter((col) => ( - col.foreignResource && (!selectedColumnNameSet || selectedColumnNameSet.has(col.name)) + col.foreignResource && (!selectedColumnNameSet || shouldAddListHelpers || selectedColumnNameSet.has(col.name)) )).map(async (col) => { let targetDataMap = {}; @@ -1493,7 +1494,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { }) ); - const pkField = resource.columns.find((col) => col.primaryKey)?.name; // remove all columns which are not defined in resources, or defined but backendOnly { const ctx = { @@ -1516,12 +1516,12 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { delete item[key]; } } - if (!selectedColumnNameSet) { + if (!selectedColumnNameSet || shouldAddListHelpers) { item._label = resource.recordLabel(item); } } } - if (!selectedColumnNameSet && source === 'list' && resource.options.listTableClickUrl) { + if (shouldAddListHelpers && resource.options.listTableClickUrl) { await Promise.all( data.data.map(async (item) => { item._clickUrl = await resource.options.listTableClickUrl(item, adminUser, resource); @@ -1551,7 +1551,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (selectedColumnNameSet) { for (const item of data.data) { for (const key of Object.keys(item)) { - if (!selectedColumnNameSet.has(key)) { + if (!selectedColumnNameSet.has(key) && key !== '_label' && key !== '_clickUrl') { delete item[key]; } } diff --git a/tests/application/resources/cars_sl_allow_create.ts b/tests/application/resources/cars_sl_allow_create.ts index c9840a378..f33e5af33 100644 --- a/tests/application/resources/cars_sl_allow_create.ts +++ b/tests/application/resources/cars_sl_allow_create.ts @@ -5,6 +5,10 @@ const cars_sl = carsResourseTemplate("cars_sl", "sqlite", "id"); export default { ...cars_sl, + options: { + ...(cars_sl as any).options, + listTableClickUrl: async (record: any, _adminUser: any, resource: any) => `/resource/${resource.resourceId}/show/${record.id}`, + }, columns: [ ...cars_sl.columns.filter(c => c.name !== "mileage" && c.name !== "photos" && c.name !== "generated_promo_picture"), { @@ -33,4 +37,4 @@ export default { editReadonly: true, }, ], -}; \ No newline at end of file +}; diff --git a/tests/jest_tests/CRUD_sqlite.test.ts b/tests/jest_tests/CRUD_sqlite.test.ts index faee622ff..ee0fc0437 100644 --- a/tests/jest_tests/CRUD_sqlite.test.ts +++ b/tests/jest_tests/CRUD_sqlite.test.ts @@ -730,9 +730,10 @@ describe('POST /get_resource_data', () => { expect(res.status).toEqual(200); expect(res.body.error).toBeUndefined(); expect(res.body.data[0]._label).toBe('🚘 Abobus amogus 🚗'); + expect(res.body.data[0]._clickUrl).toBe(`/resource/cars_sl/show/${createdRecordId}`); }); - it('returns exactly requested columns and omits computed helper fields', async () => { + it('returns requested columns with computed list helper fields', async () => { const res = await agent .set('Cookie', authCookie) .post('/adminapi/v1/get_resource_data') @@ -751,9 +752,9 @@ describe('POST /get_resource_data', () => { expect(res.body.data[0]).toEqual({ model: 'Abobus amogus', price: 1234, + _label: '🚘 Abobus amogus 🚗', + _clickUrl: `/resource/cars_sl/show/${createdRecordId}`, }); - expect(res.body.data[0]._label).toBeUndefined(); - expect(res.body.data[0]._clickUrl).toBeUndefined(); }); it('returns an error for unknown requested columns', async () => { @@ -790,7 +791,9 @@ describe('POST /get_resource_data', () => { expect(res.status).toEqual(200); expect(res.body.error).toBeUndefined(); - expect(res.body.data[0]).toEqual({}); + expect(res.body.data[0]).toEqual({ + _label: '👤 adminforth', + }); }); it('projects requested foreign columns after reference post-processing', async () => { @@ -841,11 +844,12 @@ describe('POST /get_resource_data', () => { expect(res.status).toEqual(200); expect(res.body.error).toBeUndefined(); - expect(res.body.data[0]).toEqual({ + expect(res.body.data[0]).toMatchObject({ seller_id: { label: '👤 adminforth', pk: adminUserId, }, + _label: '🚘 Abobus amogus 🚗', }); }); @@ -881,12 +885,13 @@ describe('POST /get_resource_data', () => { expect(res.status).toEqual(200); expect(res.body.error).toBeUndefined(); - expect(res.body.data[0]).toEqual({ + expect(res.body.data[0]).toMatchObject({ record_id: { label: '🚘 Abobus amogus 🚗', pk: createdRecordId, }, }); + expect(res.body.data[0]._label.startsWith('Polymorphic car refs ')).toBe(true); expect(res.body.data[0].resource_id).toBeUndefined(); }); @@ -1099,4 +1104,4 @@ describe('POST /delete_record', () => { expect(res.body.error).toBe("Operation aborted by hook"); }); -}); \ No newline at end of file +});