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
113 changes: 113 additions & 0 deletions apps/sim/app/api/table/[tableId]/query/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,119 @@ describe('POST /api/table/[tableId]/query', () => {
expect(options.withExecutions).toBe(false)
})

it('selects a stable column id and returns its current name to a workflow', async () => {
authAs('internal_jwt')
mockCheckAccess.mockResolvedValue({
ok: true,
table: createTableDefinition({
columns: [
{ id: 'col_aaa', name: 'renamed_name', type: 'string' },
{ id: 'col_bbb', name: 'wins', type: 'number' },
],
maxRows: 100,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}),
})
mockQueryRows.mockResolvedValue({
...EMPTY_RESULT,
rows: [
{
id: 'row_1',
data: { col_aaa: 'Ana' },
executions: {},
position: 1,
orderKey: 'a0',
createdAt: new Date('2026-08-20T10:00:00.000Z'),
updatedAt: new Date('2026-08-20T10:00:00.000Z'),
},
],
rowCount: 1,
totalCount: 1,
limit: 100,
})

const res = await callQuery({ workspaceId: 'workspace-1', columns: ['col_aaa'] })

expect(res.status).toBe(200)
// The service projects (so the byte budget measures the response); the route only resolves ids.
expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set(['col_aaa']))
expect((await res.json()).data.rows[0].data).toEqual({ renamed_name: 'Ana' })
})

it('accepts an exact column name for direct callers', async () => {
authAs('internal_jwt')
mockQueryRows.mockResolvedValue({
...EMPTY_RESULT,
rows: [
{
id: 'row_1',
data: { col_bbb: 12 },
executions: {},
position: 1,
orderKey: 'a0',
createdAt: new Date('2026-08-20T10:00:00.000Z'),
updatedAt: new Date('2026-08-20T10:00:00.000Z'),
},
],
rowCount: 1,
totalCount: 1,
limit: 100,
})

const res = await callQuery({ workspaceId: 'workspace-1', columns: ['wins'] })

expect(res.status).toBe(200)
expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set(['col_bbb']))
expect((await res.json()).data.rows[0].data).toEqual({ wins: 12 })
})

it('asks for every column when the selection is omitted or empty', async () => {
authAs('internal_jwt')

const omitted = await callQuery({ workspaceId: 'workspace-1' })
const empty = await callQuery({ workspaceId: 'workspace-1', columns: [] })

expect(omitted.status).toBe(200)
expect(empty.status).toBe(200)
expect(mockQueryRows.mock.calls[0][1].columnIds).toBeUndefined()
expect(mockQueryRows.mock.calls[1][1].columnIds).toBeUndefined()
})

it('drops a column reference that no longer exists and reports it instead of failing', async () => {
authAs('internal_jwt')
const staleId = `col_${'0'.repeat(32)}`

const res = await callQuery({
workspaceId: 'workspace-1',
columns: ['col_aaa', 'missing', staleId],
})

expect(res.status).toBe(200)
expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set(['col_aaa']))
expect((await res.json()).data.ignoredColumns).toEqual(['missing', staleId])
})

it('returns empty row data, not every column, when no requested column exists', async () => {
authAs('internal_jwt')

const res = await callQuery({ workspaceId: 'workspace-1', columns: ['missing'] })

expect(res.status).toBe(200)
expect(mockQueryRows.mock.calls[0][1].columnIds).toEqual(new Set())
expect((await res.json()).data.ignoredColumns).toEqual(['missing'])
})

it('reports no ignored columns when every requested column exists or none were requested', async () => {
authAs('internal_jwt')

const selected = await callQuery({ workspaceId: 'workspace-1', columns: ['col_aaa'] })
const all = await callQuery({ workspaceId: 'workspace-1' })

expect((await selected.json()).data.ignoredColumns).toEqual([])
expect((await all.json()).data.ignoredColumns).toEqual([])
})

it('accepts a root condition and executes its canonical all group', async () => {
authAs('internal_jwt')
const res = await callQuery({
Expand Down
29 changes: 28 additions & 1 deletion apps/sim/app/api/table/[tableId]/query/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Sort, TableSchema } from '@/lib/table'
import { buildIdByName, sortSpecNamesToIds } from '@/lib/table/column-keys'
import {
buildIdByName,
columnMatchesRef,
getColumnId,
sortSpecNamesToIds,
} from '@/lib/table/column-keys'
import { TableQueryValidationError } from '@/lib/table/errors'
import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate'
import { assertCursorQueryBinding, decodeCursor } from '@/lib/table/rows/cursor'
Expand Down Expand Up @@ -63,6 +68,25 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu
const schema = table.schema as TableSchema
const wire = rowWireTranslators(authResult.authType, schema)
const cursor = body.cursor ? decodeCursor(body.cursor) : undefined
// A reference that matches no column is dropped, not rejected: a workflow
// whose picked column was since deleted keeps running and simply gets the
// columns that still exist (the editor shows the orphaned id so it can be
// cleared). The skipped references are returned so a typo stays visible.
let selectedColumnIds: Set<string> | undefined
const ignoredColumns: string[] = []
if (body.columns?.length) {
selectedColumnIds = new Set()
for (const reference of body.columns) {
const column = schema.columns.find((candidate) => columnMatchesRef(candidate, reference))
if (column) selectedColumnIds.add(getColumnId(column))
else ignoredColumns.push(reference)
}
if (ignoredColumns.length > 0) {
logger.warn(
`[${requestId}] Ignoring output columns not on table ${tableId}: ${ignoredColumns.join(', ')}`
)
}
}

// Predicate/sort fields are column-NAME-keyed by construction (the caller
// authors names), so validate against the schema then translate names →
Expand Down Expand Up @@ -99,6 +123,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu
// Executions are grid UI state; the v2 surface returns row data only
// and the byte budget deliberately measures just `data`.
withExecutions: false,
// Projected inside the drain so the byte budget measures the response.
columnIds: selectedColumnIds,
},
requestId
)
Expand All @@ -119,6 +145,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu
totalCount: result.totalCount,
limit: result.limit,
nextCursor: result.nextCursor,
ignoredColumns,
},
}
return createTableRowsResponse({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* @vitest-environment node
*/
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'

const { fetched } = vi.hoisted(() => ({
fetched: {
options: [
{ id: 'col_a', label: 'Email' },
{ id: 'col_b', label: 'Name' },
] as { id: string; label: string }[],
isLoadingOptions: false,
hasLoadedOptions: true,
fetchError: null as string | null,
},
}))

vi.mock('@sim/emcn', () => ({
ChipTag: ({ children }: { children?: React.ReactNode }) => <span data-chip>{children}</span>,
Combobox: ({
options,
multiSelectValues,
overlayContent,
}: {
options: { value: string; label: string; hidden?: boolean }[]
multiSelectValues?: string[]
overlayContent?: React.ReactNode
}) => (
<div>
<div data-overlay>{overlayContent}</div>
<ul>
{options
.filter((option) => !option.hidden)
.map((option) => (
<li key={option.value} data-value={option.value}>
{option.label}
{multiSelectValues?.includes(option.value) ? ' [selected]' : ''}
</li>
))}
</ul>
</div>
),
}))
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options',
() => ({
useFetchedOptions: () => ({
fetchedOptions: fetched.options,
isDynamic: true,
isLoadingOptions: fetched.isLoadingOptions,
hasLoadedOptions: fetched.hasLoadedOptions,
fetchError: fetched.fetchError,
hydratedOption: null,
missingOptionId: null,
refetch: () => {},
}),
})
)
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
() => ({ useSubBlockValue: () => [['col_a', 'col_gone'], () => {}] })
)
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider',
() => ({ useActiveSearchTarget: () => null })
)
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text',
() => ({ formatDisplayText: (text: string) => text })
)
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight',
() => ({ getWorkflowSearchLabelHighlight: () => undefined })
)
vi.mock('@/hooks/use-operation-access', () => ({
useOperationAccess: () => ({
getDeniedOperations: () => new Set<string>(),
resolveDefaultOperation: () => undefined,
isPermissionLoading: false,
}),
}))
vi.mock('@/executor/handlers/response/response-handler', () => ({ ResponseBlockHandler: {} }))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: (selector: (state: unknown) => unknown) => selector({ blocks: {} }),
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: (selector: (state: unknown) => unknown) =>
selector({ activeWorkflowId: 'wf-1' }),
}))
vi.mock('@/stores/workflows/subblock/store', () => ({
useSubBlockStore: (selector: (state: unknown) => unknown) => selector({ workflowValues: {} }),
}))

import { Dropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown'

function render(): string {
return renderToStaticMarkup(
<Dropdown
blockId='block-1'
subBlockId='outputColumns'
multiSelect
selectorKey='table.outputColumns'
preserveLabelCase
placeholder='All columns'
/>
)
}

describe('Dropdown multi-select stale selections', () => {
it('renders a removable row for a selected value the loaded list lacks, shown by its id', () => {
const html = render()

expect(html).toContain('data-value="col_gone"')
expect(html).toContain('col_gone [selected]')
expect(html).toContain('<span class="truncate">col_gone</span>')
expect(html).toContain('Email [selected]')
})

it('adds no row for a selection before the list has loaded', () => {
fetched.isLoadingOptions = true
fetched.hasLoadedOptions = false
try {
const html = render()
expect(html).toContain('<span class="truncate">col_gone</span>')
expect(html).not.toContain('data-value="col_gone"')
} finally {
fetched.isLoadingOptions = false
fetched.hasLoadedOptions = true
}
})

it('still offers removable rows when the loaded list is empty (every column deleted)', () => {
const previous = fetched.options
fetched.options = []
try {
const html = render()
expect(html).toContain('data-value="col_a"')
expect(html).toContain('data-value="col_gone"')
} finally {
fetched.options = previous
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
OPERATION_SUBBLOCK_ID,
} from '@/lib/permission-groups/operation-access'
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
import { staleSelectionOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/stale-selections'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options'
Expand All @@ -24,6 +25,9 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/** Shared empty list, so a selector-backed field with no static options keeps a stable identity. */
const EMPTY_OPTIONS: DropdownOption[] = []

/** Shared empty list, so a multi-select with no value keeps a stable identity across renders. */
const EMPTY_MULTI_VALUES: string[] = []

/** Selected-value badges shown before folding the rest into a "+N" badge. */
const MAX_VISIBLE_MULTI_SELECT_BADGES = 2

Expand Down Expand Up @@ -135,13 +139,11 @@ export const Dropdown = memo(function Dropdown({
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue

const singleValue = multiSelect ? null : (value as string | null | undefined)
const multiValues = multiSelect
? Array.isArray(value)
? value
: value
? [value as string]
: []
: null
const multiValues = useMemo(() => {
if (!multiSelect) return null
if (Array.isArray(value)) return value
return value ? [value as string] : EMPTY_MULTI_VALUES
}, [multiSelect, value])

// Derived option lists read the block's own values (a model's valid reasoning efforts);
// `dependsOn` already re-renders this control when one of those siblings changes.
Expand All @@ -157,6 +159,7 @@ export const Dropdown = memo(function Dropdown({
const {
fetchedOptions,
isLoadingOptions,
hasLoadedOptions,
fetchError,
hydratedOption,
isDynamic,
Expand Down Expand Up @@ -201,8 +204,27 @@ export const Dropdown = memo(function Dropdown({
}
}

// A multi-select can only drop a value by clicking its row; a selection the
// loaded list no longer carries gets one so it can be removed in place.
if (multiValues && isDynamic) {
const stale = staleSelectionOptions({
selected: multiValues,
optionIds: new Set(opts.map((o) => (typeof o === 'string' ? o : o.id))),
// An empty list from a completed fetch is authoritative too (every column deleted).
listLoaded: hasLoadedOptions,
})
Comment thread
j15z marked this conversation as resolved.
if (stale.length > 0) opts = [...opts, ...stale]
}

return opts
}, [isDynamic, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
}, [
isDynamic,
normalizedFetchedOptions,
evaluatedOptions,
hydratedOption,
multiValues,
hasLoadedOptions,
])

/**
* Operation IDs whose resolved tool is denied by the caller's permission
Expand Down
Loading
Loading