Skip to content

Commit 96260f3

Browse files
committed
fix(tables): derive select operator guidance from the validator's own sets
Round 2 review. Both bots were right, and the second finding corrected me. The first fix annotated the column list but left the `filter` PARAMETER description advertising every operator for every column. That description is what a model reads when deciding the filter's shape, so the restriction has to appear there too — it now names the restricted columns inline. More importantly, the annotation itself was wrong: I omitted isNull and isNotNull. I had traced the v2 grammar through `predicateToFilter` to the `$`-prefixed sets in `column-types/select.ts`, which genuinely lack them — but the v2 query path never goes through that conversion. It compiles predicates directly in `fieldPredicate`, which gates on a SEPARATE pair of `FilterOp` sets that do allow both. Two allowlists for one concept is what made a hand-copied list wrong, so the v2 sets move next to their v1 siblings in the column-type registry — where per-type knowledge belongs — and the enrichment now derives its text from the exact sets the validator gates on. It can no longer drift. Same correction applied to the block's bestPractices, which carried the short list too.
1 parent 15822b2 commit 96260f3

6 files changed

Lines changed: 115 additions & 45 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export const TableV2Block: BlockConfig<TableQueryV2Response> = {
242242
- like/ilike use * as the wildcard (e.g. {"field":"name","op":"ilike","value":"*jo*"}).
243243
- Omit Limit to get the entire matching result in one response — the query fails with a clear error if it exceeds 5MB (narrow with a filter or set a Limit).
244244
- With a Limit, pages can end at the Limit or the 5MB byte budget, whichever comes first — pass nextCursor back as the cursor and loop until it is null; never infer completion from page size.
245-
- For substring matching on a text column use ilike with *x*. A select column accepts only a subset of the operators — eq/ne/in/nin when single, contains/ncontains when multi (a multi-select cell holds a list) — and the query is rejected outright on anything else.
245+
- For substring matching on a text column use ilike with *x*. A select column accepts only a subset of the operators — eq, ne, in, nin when single; contains, ncontains when multi (a multi-select cell holds a list, matched by option name) — plus isEmpty, isNotEmpty, isNull, isNotNull on both. The query is rejected outright on anything else.
246246
- Use Columns to Return to keep only the fields a downstream step needs (e.g. ["col_email","name"]) — the 5MB budget counts only the returned columns, so narrowing columns is another way to fit a large table; leave it empty for every column.`,
247247
docsLink: 'https://docs.sim.ai/integrations/table',
248248
category: 'blocks',

‎apps/sim/lib/table/column-types/registry.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import { jsonColumnType } from '@/lib/table/column-types/json'
2626
import { numberColumnType } from '@/lib/table/column-types/number'
2727
import {
2828
MULTI_SELECT_OPERATORS,
29+
MULTI_SELECT_OPS,
2930
SINGLE_SELECT_OPERATORS,
31+
SINGLE_SELECT_OPS,
3032
selectColumnType,
3133
} from '@/lib/table/column-types/select'
3234
import { stringColumnType } from '@/lib/table/column-types/string'
@@ -36,7 +38,7 @@ import { COLUMN_TYPES, TYPE_SPECIFIC_COLUMN_KEYS } from '@/lib/table/column-type
3638
import type { ColumnDefinition, JsonValue } from '@/lib/table/types'
3739

3840
export { COLUMN_TYPES }
39-
export { MULTI_SELECT_OPERATORS, SINGLE_SELECT_OPERATORS }
41+
export { MULTI_SELECT_OPERATORS, MULTI_SELECT_OPS, SINGLE_SELECT_OPERATORS, SINGLE_SELECT_OPS }
4042

4143
/**
4244
* Every column type, keyed by id. The annotation is the completeness gate —

‎apps/sim/lib/table/column-types/select.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
splitMultiSelectInput,
99
} from '@/lib/table/select-options'
1010
import { selectValueToNames } from '@/lib/table/select-values'
11-
import type { JsonValue } from '@/lib/table/types'
11+
import type { FilterOp, JsonValue } from '@/lib/table/types'
1212

1313
/**
1414
* Operators that make sense on a `select` column (whose values are opaque option
@@ -31,6 +31,37 @@ export const MULTI_SELECT_OPERATORS: ReadonlySet<string> = new Set([
3131
'$empty',
3232
])
3333

34+
/**
35+
* The same allowlists in the v2 bare-operator grammar, applied inside
36+
* `fieldPredicate` so both wire formats gate identically. Not derived from the
37+
* `$` sets above by string surgery because the mapping is not 1:1 — `$empty`
38+
* splits into `isEmpty`/`isNotEmpty`. `isNull`/`isNotNull` have no `$`
39+
* equivalent and are allowed on both: a strict null check is meaningful on any
40+
* column, select included.
41+
*
42+
* Exported so LLM enrichment can DERIVE the operator guidance it gives the
43+
* model rather than restating it. A hand-copied list drifts, and the cost of
44+
* drift here is a predicate the validator rejects at run time.
45+
*/
46+
export const SINGLE_SELECT_OPS: ReadonlySet<FilterOp> = new Set<FilterOp>([
47+
'eq',
48+
'ne',
49+
'in',
50+
'nin',
51+
'isEmpty',
52+
'isNotEmpty',
53+
'isNull',
54+
'isNotNull',
55+
])
56+
export const MULTI_SELECT_OPS: ReadonlySet<FilterOp> = new Set<FilterOp>([
57+
'contains',
58+
'ncontains',
59+
'isEmpty',
60+
'isNotEmpty',
61+
'isNull',
62+
'isNotNull',
63+
])
64+
3465
export const selectColumnType: ColumnTypeDefinition = {
3566
id: 'select',
3667
label: 'Select',

‎apps/sim/lib/table/llm/enrichment.test.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { MULTI_SELECT_OPS, SINGLE_SELECT_OPS } from '@/lib/table/column-types'
56
import { enrichTableToolDescription, enrichTableToolParameters } from '@/lib/table/llm/enrichment'
67
import type { TableSummary } from '@/lib/table/types'
78

@@ -101,12 +102,23 @@ describe('select columns in the v2 description', () => {
101102

102103
it('names the allowed operators on a single-select column', () => {
103104
expect(enriched).toContain(
104-
'category (single-select; only eq, ne, in, nin, isEmpty, isNotEmpty)'
105+
'category (single-select; only eq, ne, in, nin, isEmpty, isNotEmpty, isNull, isNotNull)'
105106
)
106107
})
107108

108109
it('names the allowed operators on a multi-select column', () => {
109-
expect(enriched).toContain('tags (multi-select; only contains, ncontains, isEmpty, isNotEmpty)')
110+
expect(enriched).toContain(
111+
'tags (multi-select; only contains, ncontains, isEmpty, isNotEmpty, isNull, isNotNull)'
112+
)
113+
})
114+
115+
/**
116+
* The list is derived from the sets `fieldPredicate` gates on, so it cannot
117+
* drift from the validator the way a hand-copied list did.
118+
*/
119+
it('derives the operator list from the validator sets', () => {
120+
for (const op of SINGLE_SELECT_OPS) expect(enriched).toContain(op)
121+
for (const op of MULTI_SELECT_OPS) expect(enriched).toContain(op)
110122
})
111123

112124
it('leaves non-select columns unannotated', () => {
@@ -118,7 +130,7 @@ describe('select columns in the v2 description', () => {
118130
})
119131

120132
it('steers multi-select matching to contains rather than ilike', () => {
121-
expect(enriched).toContain('match it with contains, never ilike')
133+
expect(enriched).toContain('match it by option name with contains, never ilike')
122134
})
123135
})
124136

@@ -154,6 +166,32 @@ describe('enrichTableToolParameters for table_query_rows_v2', () => {
154166
it('does not enrich a sort property that v2 does not have', () => {
155167
expect(properties.sort).toBeUndefined()
156168
})
169+
170+
/**
171+
* The parameter schema is what the model reads when deciding the filter's
172+
* shape, so the select restriction has to appear there and not only in the
173+
* tool description.
174+
*/
175+
it('carries the select restriction into the filter parameter description', () => {
176+
const withSelect = enrichTableToolParameters(
177+
V2_SCHEMA,
178+
{
179+
name: 'Transactions',
180+
columns: [
181+
{ name: 'category', type: 'select', multiple: false },
182+
{ name: 'tags', type: 'select', multiple: true },
183+
],
184+
},
185+
'table_query_rows_v2'
186+
)
187+
expect(withSelect.properties.filter.description).toContain('rejected outright')
188+
expect(withSelect.properties.filter.description).toContain('category accept only')
189+
expect(withSelect.properties.filter.description).toContain('tags hold a list')
190+
})
191+
192+
it('omits the restriction note when no column restricts operators', () => {
193+
expect(properties.filter.description).not.toContain('rejected outright')
194+
})
157195
})
158196

159197
describe('v1 enrichment is unchanged', () => {

‎apps/sim/lib/table/llm/enrichment.ts‎

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
/**
2-
* LLM tool enrichment utilities for table operations.
3-
*
4-
* Provides functions to enrich tool descriptions and parameter schemas
5-
* with table-specific information so LLMs can construct proper queries.
6-
*/
7-
8-
import { columnTypeById } from '@/lib/table/column-types'
1+
import { columnTypeById, MULTI_SELECT_OPS, SINGLE_SELECT_OPS } from '@/lib/table/column-types'
92
import type { TableSummary } from '@/lib/table/types'
103

114
/**
@@ -33,6 +26,11 @@ export const FILTER_OPERATIONS = new Set([
3326
*/
3427
const TABLE_QUERY_ROWS_V2 = 'table_query_rows_v2'
3528

29+
/** The operators a select column actually accepts, read from the validator's own sets. */
30+
function selectOperatorList(multiple: boolean | undefined): string {
31+
return Array.from(multiple ? MULTI_SELECT_OPS : SINGLE_SELECT_OPS).join(', ')
32+
}
33+
3634
/**
3735
* Renders one column line for the v2 description.
3836
*
@@ -44,9 +42,34 @@ const TABLE_QUERY_ROWS_V2 = 'table_query_rows_v2'
4442
*/
4543
function v2ColumnLine(column: TableSummary['columns'][number]): string {
4644
if (column.type !== 'select') return ` - ${column.name} (${column.type})`
47-
const allowed = column.multiple ? 'contains, ncontains' : 'eq, ne, in, nin'
4845
const kind = column.multiple ? 'multi-select' : 'single-select'
49-
return ` - ${column.name} (${kind}; only ${allowed}, isEmpty, isNotEmpty)`
46+
return ` - ${column.name} (${kind}; only ${selectOperatorList(column.multiple)})`
47+
}
48+
49+
/** Whether any column restricts its operators, i.e. whether the note is worth emitting. */
50+
function hasSelectColumn(table: TableSummary): boolean {
51+
return table.columns.some((column) => column.type === 'select')
52+
}
53+
54+
/**
55+
* A one-line summary of the per-column restrictions, for the `filter` parameter
56+
* description. The parameter schema is what a model reads when it is deciding
57+
* the SHAPE of the filter, so the restriction has to appear there too and not
58+
* only in the tool description.
59+
*/
60+
function selectRestrictionNote(table: TableSummary): string {
61+
const single = table.columns.filter((c) => c.type === 'select' && !c.multiple).map((c) => c.name)
62+
const multi = table.columns.filter((c) => c.type === 'select' && c.multiple).map((c) => c.name)
63+
const parts: string[] = []
64+
if (single.length > 0) {
65+
parts.push(`${single.join(', ')} accept only ${selectOperatorList(false)}`)
66+
}
67+
if (multi.length > 0) {
68+
parts.push(
69+
`${multi.join(', ')} hold a list and accept only ${selectOperatorList(true)} (match by option name)`
70+
)
71+
}
72+
return ` Restricted columns - a predicate using any other operator on one is rejected outright: ${parts.join('; ')}.`
5073
}
5174

5275
/**
@@ -118,7 +141,7 @@ INSTRUCTIONS:
118141
3. For multiple conditions wrap them in {"all":[...]} for AND or {"any":[...]} for OR; groups nest
119142
4. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty
120143
5. like/ilike use * as the wildcard, e.g. {"field":"name","op":"ilike","value":"*jo*"}
121-
6. Any column listed below with a restricted operator set accepts ONLY those operators - the query is rejected outright otherwise. A multi-select cell holds a list, so match it with contains, never ilike
144+
6. Any column listed below with a restricted operator set accepts ONLY those operators - the query is rejected outright otherwise. A multi-select cell holds a list, so match it by option name with contains, never ilike
122145
7. For substring matching on a text column use ilike with *x*
123146
8. For ranking queries (highest, lowest, Nth, top N) set order and a small limit, e.g. limit 1 for the highest, 2 for the second highest
124147
9. Omit limit to return every matching row; the query fails if the result exceeds 5MB, so narrow with a filter instead of guessing a limit
@@ -233,7 +256,7 @@ export function enrichTableToolParameters(
233256
if (enrichedProperties.filter) {
234257
enrichedProperties.filter = {
235258
...enrichedProperties.filter,
236-
description: `Predicate built from the user's question using columns: ${columnNames}. One condition is {"field":"<column>","op":"<operator>","value":<value>}; combine with {"all":[...]} for AND or {"any":[...]} for OR. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. Omit only to match every row.`,
259+
description: `Predicate built from the user's question using columns: ${columnNames}. One condition is {"field":"<column>","op":"<operator>","value":<value>}; combine with {"all":[...]} for AND or {"any":[...]} for OR. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty.${hasSelectColumn(table) ? selectRestrictionNote(table) : ''} Omit only to match every row.`,
237260
}
238261
}
239262

‎apps/sim/lib/table/sql.ts‎

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import {
1515
columnTypeOf,
1616
filterOperatorsFor,
1717
MULTI_SELECT_OPERATORS,
18+
MULTI_SELECT_OPS,
1819
SINGLE_SELECT_OPERATORS,
20+
SINGLE_SELECT_OPS,
1921
} from '@/lib/table/column-types'
2022
import { NAME_PATTERN } from '@/lib/table/constants'
2123
import { normalizeDateCellValue } from '@/lib/table/dates'
@@ -36,38 +38,12 @@ import type {
3638
* Re-exported: the `$`-prefixed wire whitelists now live with the `select` type
3739
* definition, but this module is where callers and tests already look for them.
3840
*/
39-
export { MULTI_SELECT_OPERATORS, SINGLE_SELECT_OPERATORS }
41+
export { MULTI_SELECT_OPERATORS, MULTI_SELECT_OPS, SINGLE_SELECT_OPERATORS, SINGLE_SELECT_OPS }
4042

4143
type ColumnType = ColumnDefinition['type']
4244
type ColumnMap = ReadonlyMap<string, ColumnDefinition>
4345

4446
/**
45-
/**
46-
* The same allowlists in the v2 bare-operator grammar, applied inside
47-
* `fieldPredicate` so both wire formats gate identically. Not derived from the
48-
* `$` sets above by string surgery because the mapping is not 1:1 — `$empty`
49-
* splits into `isEmpty`/`isNotEmpty`. `isNull`/`isNotNull` have no `$`
50-
* equivalent and are allowed on both: a strict null check is meaningful on any
51-
* column, select included.
52-
*/
53-
const SINGLE_SELECT_OPS = new Set<FilterOp>([
54-
'eq',
55-
'ne',
56-
'in',
57-
'nin',
58-
'isEmpty',
59-
'isNotEmpty',
60-
'isNull',
61-
'isNotNull',
62-
])
63-
const MULTI_SELECT_OPS = new Set<FilterOp>([
64-
'contains',
65-
'ncontains',
66-
'isEmpty',
67-
'isNotEmpty',
68-
'isNull',
69-
'isNotNull',
70-
])
7147
7248
/**
7349
* Returns the Postgres cast needed to compare a JSONB text value of the given

0 commit comments

Comments
 (0)