Skip to content

Commit 4f9c104

Browse files
fix(table): resolve select operands for contains/ncontains on multi-select
Caught by driving mothership at a real multi-select table. It sent a correctly formed predicate — {field:'Color', op:'contains', value:'Teal'} — and got zero rows with success, against a table where 15 rows hold Teal. resolvePredicateSelectValues only resolved eq/ne/in/nin. I excluded contains/ncontains as 'pattern ops that match the raw stored cell', which holds for a string column but not for a multi-select: there the cell is an array of option ids and those two ops express MEMBERSHIP, so their operand is an option name that has to become an option id. It is the primary way to filter a multi-select, so the one op that mattered most was the one left out. The $-grammar sibling resolveFilterSelectValues has always handled $contains/$ncontains for this exact reason; the omission was mine, porting it. The remaining pattern ops (like/startsWith/...) never reach here — fieldPredicate's select allowlist rejects them on select columns first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
1 parent 1564fdd commit 4f9c104

2 files changed

Lines changed: 137 additions & 5 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Select-column operand resolution. A select cell stores option IDs, so a filter
5+
* written with the option NAME must be rewritten before it reaches SQL —
6+
* otherwise it compares a name against an id and matches nothing while reporting
7+
* success. Both wire grammars have to do this identically.
8+
*/
9+
import { describe, expect, it } from 'vitest'
10+
import { resolveFilterSelectValues, resolvePredicateSelectValues } from '@/lib/table/select-values'
11+
import type { ColumnDefinition } from '@/lib/table/types'
12+
13+
const MULTI: ColumnDefinition = {
14+
id: 'col_color',
15+
name: 'Color',
16+
type: 'select',
17+
multiple: true,
18+
options: [
19+
{ id: 'opt_teal', name: 'Teal' },
20+
{ id: 'opt_green', name: 'Green' },
21+
],
22+
}
23+
const SINGLE: ColumnDefinition = {
24+
id: 'col_status',
25+
name: 'Status',
26+
type: 'select',
27+
options: [{ id: 'opt_open', name: 'Open' }],
28+
}
29+
const PLAIN: ColumnDefinition = { id: 'col_name', name: 'name', type: 'string' }
30+
const COLS = [MULTI, SINGLE, PLAIN]
31+
32+
const leaf = (p: unknown) => (p as { all: Array<{ value: unknown }> }).all[0]
33+
34+
describe('resolvePredicateSelectValues', () => {
35+
/**
36+
* Regression: `contains`/`ncontains` were excluded as "pattern ops". On a
37+
* multi-select they are not pattern ops — the cell is an array of ids and they
38+
* express membership. Mothership sent exactly this and silently got zero rows.
39+
*/
40+
it('resolves contains / ncontains on a MULTI-select (membership, not pattern)', () => {
41+
for (const op of ['contains', 'ncontains'] as const) {
42+
const out = resolvePredicateSelectValues(
43+
{ all: [{ field: 'col_color', op, value: 'Teal' }] },
44+
COLS
45+
)
46+
expect(leaf(out).value).toBe('opt_teal')
47+
}
48+
})
49+
50+
it('resolves eq / ne / in / nin on a single select', () => {
51+
expect(
52+
leaf(
53+
resolvePredicateSelectValues(
54+
{ all: [{ field: 'col_status', op: 'eq', value: 'Open' }] },
55+
COLS
56+
)
57+
).value
58+
).toBe('opt_open')
59+
expect(
60+
leaf(
61+
resolvePredicateSelectValues(
62+
{ all: [{ field: 'col_status', op: 'in', value: ['Open'] }] },
63+
COLS
64+
)
65+
).value
66+
).toEqual(['opt_open'])
67+
})
68+
69+
it('matches option names case-insensitively and passes ids through', () => {
70+
expect(
71+
leaf(
72+
resolvePredicateSelectValues(
73+
{ all: [{ field: 'col_color', op: 'contains', value: 'teal' }] },
74+
COLS
75+
)
76+
).value
77+
).toBe('opt_teal')
78+
expect(
79+
leaf(
80+
resolvePredicateSelectValues(
81+
{ all: [{ field: 'col_color', op: 'contains', value: 'opt_teal' }] },
82+
COLS
83+
)
84+
).value
85+
).toBe('opt_teal')
86+
})
87+
88+
it('leaves non-select columns and unknown option names alone', () => {
89+
expect(
90+
leaf(
91+
resolvePredicateSelectValues(
92+
{ all: [{ field: 'col_name', op: 'contains', value: 'Teal' }] },
93+
COLS
94+
)
95+
).value
96+
).toBe('Teal')
97+
expect(
98+
leaf(
99+
resolvePredicateSelectValues(
100+
{ all: [{ field: 'col_color', op: 'contains', value: 'Nope' }] },
101+
COLS
102+
)
103+
).value
104+
).toBe('Nope')
105+
})
106+
107+
it('recurses through nested groups', () => {
108+
const out = resolvePredicateSelectValues(
109+
{ any: [{ all: [{ field: 'col_color', op: 'contains', value: 'Green' }] }] },
110+
COLS
111+
) as { any: Array<{ all: Array<{ value: unknown }> }> }
112+
expect(out.any[0].all[0].value).toBe('opt_green')
113+
})
114+
115+
/** The two grammars must resolve the same operand set, or they drift. */
116+
it('agrees with the $-grammar sibling on the same filter', () => {
117+
const legacy = resolveFilterSelectValues({ col_color: { $contains: 'Teal' } }, COLS)
118+
expect((legacy.col_color as { $contains: unknown }).$contains).toBe('opt_teal')
119+
expect(
120+
leaf(
121+
resolvePredicateSelectValues(
122+
{ all: [{ field: 'col_color', op: 'contains', value: 'Teal' }] },
123+
COLS
124+
)
125+
).value
126+
).toBe('opt_teal')
127+
})
128+
})

apps/sim/lib/table/select-values.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,14 @@ export function resolveFilterSelectValues(filter: Filter, columns: ColumnDefinit
116116
* op:'eq', value:'Open'}`) compares the option NAME against the stored option
117117
* ID and silently matches nothing.
118118
*
119-
* Only value-carrying comparison ops are resolved. Pattern ops (`contains`,
120-
* `like`, …) are deliberately left alone: they match against the raw stored
121-
* cell, and rewriting their operand to an id would change what the user asked
122-
* for. The valueless ops carry nothing to resolve.
119+
* Mirrors the `$`-grammar set exactly: equality/membership on a single select
120+
* (`eq`/`ne`/`in`/`nin`) AND `contains`/`ncontains`, which on a MULTI-select are
121+
* not pattern matches at all — the cell is an array of option ids, so those ops
122+
* express membership and their operand is an option, not a substring. Leaving
123+
* them out is what made a correctly-formed multi-select filter match nothing.
124+
* The remaining pattern ops (`like`, `startsWith`, …) are rejected on select
125+
* columns by `fieldPredicate`'s allowlist, so they never reach here. The
126+
* valueless ops carry nothing to resolve.
123127
*/
124128
export function resolvePredicateSelectValues(
125129
predicate: TablePredicate,
@@ -130,7 +134,7 @@ export function resolvePredicateSelectValues(
130134
)
131135
if (selectById.size === 0) return predicate
132136

133-
const RESOLVED_OPS = new Set<FilterOp>(['eq', 'ne', 'in', 'nin'])
137+
const RESOLVED_OPS = new Set<FilterOp>(['eq', 'ne', 'in', 'nin', 'contains', 'ncontains'])
134138

135139
const walk = (node: PredicateNode): PredicateNode => {
136140
if ('all' in node) return { all: node.all.map(walk) }

0 commit comments

Comments
 (0)