Skip to content

Commit e675e62

Browse files
authored
feat(table): add Columns to Return to the legacy Table block's Query Rows (#9887)
* feat(table): add Columns to Return to the legacy Table block's Query Rows * fix(table): treat a column selection with no resolvable columns as all columns * Revert "fix(table): treat a column selection with no resolvable columns as all columns" This reverts commit 1b59b12.
1 parent b719649 commit e675e62

12 files changed

Lines changed: 145 additions & 61 deletions

File tree

‎apps/sim/app/api/table/[tableId]/rows/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const GET = defineInternalJsonRoute({
124124
limit: query.limit,
125125
offset: query.offset,
126126
after: query.after,
127+
columns: query.columns,
127128
includeTotal: query.includeTotal,
128129
includeRunState: query.limit !== undefined && query.limit <= TABLE_LIMITS.MAX_QUERY_LIMIT,
129130
allowExpandedLimit: true,

‎apps/sim/blocks/blocks/table.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,13 @@ describe('table query_rows transformer', () => {
2323
/Invalid number for Limit/
2424
)
2525
})
26+
27+
it('passes selected output columns to the query tool and treats an empty selection as all', () => {
28+
expect(
29+
params({ operation: 'query_rows', tableId: 'table-1', outputColumns: ['Email'] }).columns
30+
).toEqual(['Email'])
31+
expect(
32+
params({ operation: 'query_rows', tableId: 'table-1', outputColumns: [] }).columns
33+
).toBeUndefined()
34+
})
2635
})

‎apps/sim/blocks/blocks/table.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { toError } from '@sim/utils/errors'
33
import { TABLE_LIMITS } from '@/lib/table/constants'
44
import { filterRulesToFilter, sortRulesToSort } from '@/lib/table/query-builder/converters'
55
import type { BlockConfig } from '@/blocks/types'
6-
import { parseOptionalNumberInput } from '@/blocks/utils'
6+
import {
7+
parseOptionalNumberInput,
8+
resolveTableColumnSelection,
9+
TABLE_OUTPUT_COLUMNS_INPUT,
10+
TABLE_OUTPUT_COLUMNS_SUBBLOCK,
11+
} from '@/blocks/utils'
712
import { TABLE_ID_PARAM } from '@/tools/table/params'
813
import { getTrigger } from '@/triggers'
914

@@ -52,6 +57,9 @@ interface TableBlockParams {
5257
rows?: string | unknown
5358
filter?: string | unknown
5459
sort?: string | unknown
60+
outputColumns?: unknown
61+
/** The tool's own `columns` param, supplied by a model when the block runs as an Agent tool. */
62+
columns?: unknown
5563
limit?: string
5664
offset?: string
5765
builderMode?: string
@@ -178,6 +186,7 @@ const paramTransformers: Record<string, (params: TableBlockParams) => ParsedPara
178186

179187
return {
180188
tableId: params.tableId,
189+
columns: resolveTableColumnSelection(params),
181190
filter,
182191
sort,
183192
limit: parseOptionalNumberInput(params.limit, 'Limit', {
@@ -327,6 +336,8 @@ export const TableBlock: BlockConfig = {
327336
required: true,
328337
},
329338

339+
TABLE_OUTPUT_COLUMNS_SUBBLOCK,
340+
330341
// Row ID for get/update/delete
331342
{
332343
id: 'rowId',
@@ -739,6 +750,7 @@ Return ONLY the sort JSON:`,
739750
data: { type: 'json', description: 'Row data for insert/update' },
740751
rows: { type: 'array', description: 'Array of row data for batch insert' },
741752
rowId: { type: 'string', description: 'Row identifier for ID-based operations' },
753+
outputColumns: TABLE_OUTPUT_COLUMNS_INPUT,
742754
bulkFilterMode: {
743755
type: 'string',
744756
description: 'Filter input mode for bulk operations (builder or json)',

‎apps/sim/blocks/blocks/table_v2.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('table_v2 query_rows transformer', () => {
3131
it('fails fast instead of silently returning every column for a malformed selection', () => {
3232
expect(() =>
3333
params({ operation: 'query_rows', tableId: 't', outputColumns: 'col_name,col_email' })
34-
).toThrow(/Invalid JSON in Columns to Return/)
34+
).toThrow(/Invalid JSON for Columns to Return/)
3535
expect(() =>
3636
params({ operation: 'query_rows', tableId: 't', outputColumns: { col_name: true } })
3737
).toThrow(/Invalid Columns to Return/)

‎apps/sim/blocks/blocks/table_v2.ts‎

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import type {
1212
TablePredicateInput,
1313
} from '@/lib/table/types'
1414
import type { BlockConfig } from '@/blocks/types'
15+
import {
16+
resolveTableColumnSelection,
17+
TABLE_OUTPUT_COLUMNS_INPUT,
18+
TABLE_OUTPUT_COLUMNS_SUBBLOCK,
19+
} from '@/blocks/utils'
1520
import { TABLE_ID_PARAM } from '@/tools/table/params'
1621
import { getTrigger } from '@/triggers'
1722

@@ -90,28 +95,6 @@ function resolveOrder(params: TableBlockParams): SortSpec | undefined {
9095
return (parsed as SortSpec | undefined) || undefined
9196
}
9297

93-
/**
94-
* Resolves the "Columns to Return" selection. Only an absent value (`undefined`,
95-
* `null`, the dependsOn-cleared `''`, or `[]`) means "all columns"; anything
96-
* else must be a list of column ids/names. A malformed value fails fast rather
97-
* than silently widening a deliberately narrowed query to every column — the
98-
* same stance `parseOptionalLimit` takes for a malformed limit. A JSON string is
99-
* accepted because an agent-authored block config serializes arrays that way.
100-
*/
101-
function resolveOutputColumns(raw: unknown): string[] | undefined {
102-
if (raw === undefined || raw === null || raw === '') return undefined
103-
const parsed = typeof raw === 'string' ? parseJSON(raw, 'Columns to Return') : raw
104-
if (!Array.isArray(parsed)) {
105-
throw new Error('Invalid Columns to Return: expected a list of column ids or names')
106-
}
107-
if (parsed.length === 0) return undefined
108-
const columns = parsed.map((column) => (typeof column === 'string' ? column.trim() : ''))
109-
if (columns.some((column) => column.length === 0)) {
110-
throw new Error('Invalid Columns to Return: every entry must be a non-empty column id or name')
111-
}
112-
return columns
113-
}
114-
11598
interface ParsedParams {
11699
tableId?: string
117100
rowId?: string
@@ -200,9 +183,7 @@ const paramTransformers: Record<string, (params: TableBlockParams) => ParsedPara
200183

201184
query_rows: (params) => ({
202185
tableId: params.tableId,
203-
// The canvas selection wins; a model-supplied `columns` (Agent tool use) is the
204-
// fallback so it is not clobbered by an empty canvas value when merged.
205-
columns: resolveOutputColumns(params.outputColumns) ?? resolveOutputColumns(params.columns),
186+
columns: resolveTableColumnSelection(params),
206187
filter: resolveFilter(params),
207188
order: resolveOrder(params),
208189
// Omitted limit returns the entire result (server fails fast over 5MB);
@@ -342,19 +323,7 @@ export const TableV2Block: BlockConfig = {
342323
required: true,
343324
},
344325

345-
{
346-
id: 'outputColumns',
347-
title: 'Columns to Return',
348-
type: 'dropdown',
349-
selectorKey: 'table.outputColumns',
350-
multiSelect: true,
351-
searchable: true,
352-
preserveLabelCase: true,
353-
placeholder: 'All columns',
354-
description: 'Only include these columns in each returned row. Leave empty for all columns.',
355-
dependsOn: { any: ['tableSelector', 'manualTableId'] },
356-
condition: { field: 'operation', value: 'query_rows' },
357-
},
326+
TABLE_OUTPUT_COLUMNS_SUBBLOCK,
358327

359328
{
360329
id: 'rowId',
@@ -590,10 +559,7 @@ Return ONLY the JSON object:`,
590559
data: { type: 'json', description: 'Row data for insert/update' },
591560
rows: { type: 'array', description: 'Array of row data for batch insert' },
592561
rowId: { type: 'string', description: 'Row identifier for ID-based operations' },
593-
outputColumns: {
594-
type: 'array',
595-
description: 'Table columns to include in each queried row; omit for all columns',
596-
},
562+
outputColumns: TABLE_OUTPUT_COLUMNS_INPUT,
597563
filterInput: {
598564
type: 'json',
599565
description:

‎apps/sim/blocks/utils.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,60 @@ export function parseOptionalJsonInput<T = unknown>(value: unknown, label: strin
703703
return value as T
704704
}
705705

706+
const COLUMN_SELECTION_LABEL = 'Columns to Return'
707+
708+
function parseColumnSelection(raw: unknown): string[] | undefined {
709+
const parsed = parseOptionalJsonInput<unknown>(raw, COLUMN_SELECTION_LABEL)
710+
if (parsed === undefined || parsed === null) return undefined
711+
if (!Array.isArray(parsed)) {
712+
throw new Error(`Invalid ${COLUMN_SELECTION_LABEL}: expected a list of column ids or names`)
713+
}
714+
if (parsed.length === 0) return undefined
715+
const columns = parsed.map((column) => (typeof column === 'string' ? column.trim() : ''))
716+
if (columns.some((column) => column.length === 0)) {
717+
throw new Error(
718+
`Invalid ${COLUMN_SELECTION_LABEL}: every entry must be a non-empty column id or name`
719+
)
720+
}
721+
return columns
722+
}
723+
724+
/**
725+
* Resolves a Table block's "Columns to Return" for a query. The canvas selection wins; a
726+
* model-supplied `columns` (Agent tool use) is the fallback, so an empty canvas value does not
727+
* clobber it. Only an absent value (`undefined`, `null`, `''`, or `[]`) means "all columns";
728+
* anything else must be a list of column ids/names, and a malformed value fails fast rather than
729+
* silently widening a deliberately narrowed query. A JSON string is accepted because an
730+
* agent-authored block config serializes arrays that way.
731+
*/
732+
export function resolveTableColumnSelection(params: {
733+
outputColumns?: unknown
734+
columns?: unknown
735+
}): string[] | undefined {
736+
return parseColumnSelection(params.outputColumns) ?? parseColumnSelection(params.columns)
737+
}
738+
739+
/** The Table blocks' "Columns to Return" picker, shown for Query Rows. */
740+
export const TABLE_OUTPUT_COLUMNS_SUBBLOCK: SubBlockConfig = {
741+
id: 'outputColumns',
742+
title: COLUMN_SELECTION_LABEL,
743+
type: 'dropdown',
744+
selectorKey: 'table.outputColumns',
745+
multiSelect: true,
746+
searchable: true,
747+
preserveLabelCase: true,
748+
placeholder: 'All columns',
749+
description: 'Only include these columns in each returned row. Leave empty for all columns.',
750+
dependsOn: { any: ['tableSelector', 'manualTableId'] },
751+
condition: { field: 'operation', value: 'query_rows' },
752+
}
753+
754+
/** Block input entry for {@link TABLE_OUTPUT_COLUMNS_SUBBLOCK}. */
755+
export const TABLE_OUTPUT_COLUMNS_INPUT = {
756+
type: 'array',
757+
description: 'Table columns to include in each queried row; omit for all columns',
758+
} as const
759+
706760
/**
707761
* Parses an optional numeric block input value.
708762
* Returns `undefined` for empty values and throws when the provided value is not a valid number.

‎apps/sim/lib/api/contracts/tables.ts‎

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,23 @@ const jsonQueryValue = <S extends z.ZodType>(schema: S) =>
783783
}
784784
}, schema)
785785

786+
/** Columns to keep in each returned row's data, by stable id or name; omitted or empty means all. */
787+
const rowColumnSelectionSchema = z
788+
.array(
789+
requiredFieldSchema('Column reference must not be empty').max(
790+
MAX_ID_LENGTH,
791+
'Column reference is too long'
792+
)
793+
)
794+
.max(
795+
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
796+
`Cannot select more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
797+
)
798+
.optional()
799+
.describe(
800+
'Stable column identifiers or column names to include. Omit or pass an empty array for all columns; a reference that matches no column is ignored.'
801+
)
802+
786803
/** Unrefined base so v1 contracts can `.extend()` — consumers use {@link tableRowsQuerySchema}. */
787804
export const tableRowsQueryBaseSchema = z.object({
788805
workspaceId: workspaceIdSchema,
@@ -849,7 +866,10 @@ const unboundedTableRowsLimitSchema = z.preprocess(
849866
)
850867

851868
export const tableRowsQuerySchema = tableRowsQueryBaseSchema
852-
.extend({ limit: unboundedTableRowsLimitSchema })
869+
.extend({
870+
limit: unboundedTableRowsLimitSchema,
871+
columns: jsonQueryValue(rowColumnSelectionSchema).optional(),
872+
})
853873
.refine((data) => !(data.after && data.sort), {
854874
message: 'after cursor cannot be combined with sort — cursors paginate the default order',
855875
})
@@ -1074,21 +1094,7 @@ export const rowQueryBodySchema = z.object({
10741094
workspaceId: z.string().min(1, 'Workspace ID is required'),
10751095
predicate: predicateInputSchema.optional(),
10761096
sort: sortSpecSchema.optional(),
1077-
columns: z
1078-
.array(
1079-
requiredFieldSchema('Column reference must not be empty').max(
1080-
MAX_ID_LENGTH,
1081-
'Column reference is too long'
1082-
)
1083-
)
1084-
.max(
1085-
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
1086-
`Cannot select more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
1087-
)
1088-
.optional()
1089-
.describe(
1090-
'Stable column identifiers or column names to include. Omit or pass an empty array for all columns; a reference that matches no column is ignored.'
1091-
),
1097+
columns: rowColumnSelectionSchema,
10921098
// Omitted limit returns the ENTIRE matching result, failing fast (400) when
10931099
// it exceeds the response byte budget. An explicit limit caps the page row
10941100
// count; the byte budget may still end a page early with nextCursor set.

‎apps/sim/lib/internal/table/execute-tool.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ vi.mock('@/lib/internal/table/operations', () => ({
4040
}))
4141

4242
import { executeTableTool } from '@/lib/internal/table/execute-tool'
43+
import { tableQueryRowsTool } from '@/tools/table/query_rows'
4344

4445
const { mockCreateExecutorPrincipalFromExecutionContext } = executorPrincipalMockFns
4546

@@ -144,6 +145,30 @@ describe('executeTableTool', () => {
144145
})
145146
})
146147

148+
it('carries a legacy query tool column selection through the query-string contract', async () => {
149+
const input = tableQueryRowsTool.operation.input?.({
150+
tableId: 'table-1',
151+
columns: ['Email', 'col_status'],
152+
limit: 10,
153+
_context: { workspaceId: 'workspace-1' },
154+
} as never)
155+
156+
const response = await executeTableTool({
157+
toolId: 'table_query_rows',
158+
input,
159+
headers: new Headers(),
160+
context: CONTEXT,
161+
requestId: 'request-1',
162+
})
163+
164+
expect(response.status).toBe(200)
165+
expect(mocks.queryRows).toHaveBeenCalledWith(
166+
'table-1',
167+
expect.objectContaining({ columns: ['Email', 'col_status'] }),
168+
expect.anything()
169+
)
170+
})
171+
147172
it('projects a stale workflow binding as authentication failure', async () => {
148173
mockCreateExecutorPrincipalFromExecutionContext.mockRejectedValueOnce(
149174
new InvalidInternalDelegationBindingError()

‎apps/sim/lib/internal/table/operations.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ export async function executeTableQueryRows(
233233
legacyKeying: 'names',
234234
limit: query.limit,
235235
offset: query.offset,
236+
columns: query.columns,
236237
includeTotal: query.includeTotal,
237238
includeRunState: query.limit !== undefined && query.limit <= TABLE_LIMITS.MAX_QUERY_LIMIT,
238239
allowExpandedLimit: true,

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)