Skip to content

Commit d9cad69

Browse files
committed
fix(tables): align the copilot retype with the routes; add a BC harness
The copilot tool's retype passed `options`/`multiple` from separately normalized variables while the HTTP routes spread both the generic AND dedicated key sets. `normalizeSelectOptionsInput` answers `undefined` for a non-array, so an agent sending `options: null` reached the conversion as "not mentioned" and it carried the stale option set forward, where the route would have cleared it. It now spreads both sets from the same patch, which already holds the normalized options. Adds `backward-compatibility.test.ts`, which pins what an EXISTING table does rather than testing the new code. The consolidation rewrote how every consumer answers per-type questions — casts, coercion, filter operands, sort, display, import — and each of those fails SILENTLY when wrong: a wrong cast errors a filter, a wrong operand returns the wrong rows, a wrong coercion nulls a cell on the next write. 51 cases across the seven pre-existing types: bare definitions still validate without the new metadata keys, legacy cell values coerce identically, a legacy date column still stores full instants, a number column with no `precision` still renders as stored, filter and sort SQL emit the same casts, an unknown field keeps `::numeric`, a select option id is never coerced, and CSV inference still refuses to guess email or phone. Verified the harness actually bites by reverting three fixes in turn — the unknown-column fallback, the legacy-date reading, and email inference — each of which fails it.
1 parent 1027349 commit d9cad69

2 files changed

Lines changed: 271 additions & 3 deletions

File tree

apps/sim/lib/copilot/tools/server/table/user-table.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,9 +1785,18 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
17851785
tableId: args.tableId,
17861786
columnName: colName,
17871787
newType: newType as (typeof COLUMN_TYPES)[number],
1788-
options,
1789-
multiple,
1790-
...pickMetadata(metadataUpdates, genericMetadataKeys),
1788+
// Both key sets, exactly as the HTTP routes do. Passing
1789+
// `options`/`multiple` from the separately-normalized variables
1790+
// dropped an explicit clear: `normalizeSelectOptionsInput`
1791+
// answers `undefined` for a non-array, so an agent sending
1792+
// `options: null` reached the retype as "not mentioned" and the
1793+
// conversion carried the stale option set forward, where the
1794+
// route would have cleared it. `metadataUpdates` already holds
1795+
// the NORMALIZED options when any were supplied.
1796+
...pickMetadata(metadataUpdates, [
1797+
...genericMetadataKeys,
1798+
...dedicatedMetadataKeys,
1799+
]),
17911800
...(uniqFlag !== undefined ? { unique: uniqFlag } : {}),
17921801
},
17931802
requestId
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Backward-compatibility guards for columns and data that predate the
5+
* column-type registry work.
6+
*
7+
* The registry consolidation rewrote how every consumer answers per-type
8+
* questions — casts, coercion, filter operands, display, import. None of that
9+
* is allowed to change what an EXISTING table does, and most of it would fail
10+
* silently if it did: a wrong cast makes a filter error, a wrong operand makes
11+
* it return the wrong rows, a wrong coercion nulls a cell on the next write.
12+
*
13+
* So these pin the legacy behaviour directly rather than testing the new code.
14+
* A "legacy column" here means one carrying none of the metadata keys the work
15+
* added (`precision`, `includeTime`, option `color`) — which is every column
16+
* that existed before it, since no migration backfills them.
17+
*/
18+
import { describe, expect, it } from 'vitest'
19+
import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types'
20+
import { coerceValue, inferColumnType } from '@/lib/table/import'
21+
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
22+
import type { ColumnDefinition, Filter, JsonValue, Sort } from '@/lib/table/types'
23+
import { validateColumnDefinition } from '@/lib/table/validation'
24+
25+
/** Every type that existed before this work, in a legacy (bare) definition. */
26+
const LEGACY_COLUMNS: ColumnDefinition[] = [
27+
{ id: 'c', name: 'c', type: 'string' },
28+
{ id: 'c', name: 'c', type: 'number' },
29+
{ id: 'c', name: 'c', type: 'boolean' },
30+
{ id: 'c', name: 'c', type: 'date' },
31+
{ id: 'c', name: 'c', type: 'json' },
32+
{ id: 'c', name: 'c', type: 'currency' },
33+
{ id: 'c', name: 'c', type: 'select', options: [{ id: 'o1', name: 'One' }] },
34+
]
35+
36+
const TABLE = 'user_table_rows'
37+
38+
/**
39+
* Flattens a drizzle SQL node into readable text with its operands inlined, so
40+
* a case can assert on the emitted cast and operand rather than on an opaque
41+
* object. Handles the two shapes the builders produce: a template chunk
42+
* (`strings`/`values`) and a joined list (`fragments`).
43+
*/
44+
function render(clause: unknown): string {
45+
if (clause === null || clause === undefined) return ''
46+
const node = clause as {
47+
strings?: string[]
48+
values?: unknown[]
49+
fragments?: unknown[]
50+
rawSql?: string
51+
}
52+
if (node.rawSql !== undefined) return node.rawSql
53+
if (node.fragments) return node.fragments.map(render).join(' ')
54+
if (node.strings) {
55+
const values = (node.values ?? []).map(render)
56+
return node.strings.map((part, i) => part + (values[i] ?? '')).join('')
57+
}
58+
return typeof clause === 'string' ? clause : JSON.stringify(clause)
59+
}
60+
61+
describe('legacy column definitions stay valid', () => {
62+
it.each(LEGACY_COLUMNS)('accepts a bare $type column with no new metadata', (column) => {
63+
// No migration backfills the new keys, so every pre-existing column reaches
64+
// the validator without them. Requiring one would reject the entire schema
65+
// of every existing table on its next write.
66+
const result = validateColumnDefinition(column)
67+
expect(result.valid, result.errors.join('; ')).toBe(true)
68+
})
69+
70+
it('does not require a select option to carry a colour', () => {
71+
// `color` is additive; options written before it have none.
72+
const column: ColumnDefinition = {
73+
name: 'c',
74+
type: 'select',
75+
options: [{ id: 'o1', name: 'One' }],
76+
}
77+
expect(validateColumnDefinition(column).valid).toBe(true)
78+
})
79+
})
80+
81+
describe('legacy cell values survive the write path unchanged', () => {
82+
it.each([
83+
['string', 'hello', 'hello'],
84+
['string', '123', '123'],
85+
['number', 42, 42],
86+
['number', '42', 42],
87+
['number', '1e3', 1000],
88+
['number', '-2.5', -2.5],
89+
['boolean', true, true],
90+
['boolean', 'true', true],
91+
['boolean', 'false', false],
92+
['currency', 1234.56, 1234.56],
93+
['currency', '$1,234.56', 1234.56],
94+
['date', '2024-01-15', '2024-01-15'],
95+
] as Array<[ColumnDefinition['type'], JsonValue, JsonValue]>)(
96+
'%s coerces %s to %s exactly as before',
97+
(type, input, expected) => {
98+
const column = LEGACY_COLUMNS.find((c) => c.type === type)
99+
if (!column) throw new Error(`no legacy column for ${type}`)
100+
const result = COLUMN_TYPE_REGISTRY[type].coerce(input, column)
101+
expect(result.ok && result.value).toEqual(expected)
102+
}
103+
)
104+
105+
it('keeps a legacy date column storing full instants', () => {
106+
// `includeTime` absent means "predates the key", NOT date-only. Truncating
107+
// here would destroy the time of day on the next write to any cell of every
108+
// date column that already exists.
109+
const legacy = LEGACY_COLUMNS.find((c) => c.type === 'date')
110+
if (!legacy) throw new Error('no legacy date column')
111+
const result = COLUMN_TYPE_REGISTRY.date.coerce(1700000000000, legacy)
112+
expect(result.ok && result.value).toBe('2023-11-14T22:13:20.000Z')
113+
})
114+
115+
it('renders a legacy number column with no precision exactly as stored', () => {
116+
// `precision` absent must not force decimals; a stored 1.5 stays "1.5".
117+
const legacy = LEGACY_COLUMNS.find((c) => c.type === 'number')
118+
if (!legacy) throw new Error('no legacy number column')
119+
expect(COLUMN_TYPE_REGISTRY.number.formatForDisplay(1.5, legacy)).toBe('1.5')
120+
expect(COLUMN_TYPE_REGISTRY.number.formatForDisplay(0.1 + 0.2, legacy)).toBe(
121+
'0.30000000000000004'
122+
)
123+
})
124+
})
125+
126+
describe('filter compilation is unchanged for pre-existing types', () => {
127+
it('casts a number column to numeric', () => {
128+
const out = render(buildFilterClause({ c: { $gt: 5 } } as Filter, TABLE, [LEGACY_COLUMNS[1]]))
129+
expect(out).toContain(`(${TABLE}.data->>'c')::numeric`)
130+
})
131+
132+
it('casts a date column to timestamptz', () => {
133+
const out = render(
134+
buildFilterClause({ c: { $gte: '2024-01-01' } } as Filter, TABLE, [LEGACY_COLUMNS[3]])
135+
)
136+
expect(out).toContain(`(${TABLE}.data->>'c')::timestamptz`)
137+
// A bare calendar date passes through untouched.
138+
expect(out).toContain('2024-01-01')
139+
})
140+
141+
it('compares a string column as text and keeps a numeric-looking operand a string', () => {
142+
const out = render(
143+
buildFilterClause({ c: { $eq: '123' } } as Filter, TABLE, [LEGACY_COLUMNS[0]])
144+
)
145+
// JSONB containment distinguishes "123" from 123.
146+
expect(out).toContain('"c":"123"')
147+
})
148+
149+
it('keeps ::numeric for a field with NO schema entry', () => {
150+
// Ad-hoc fields have always compared numerically. Switching them to
151+
// lexicographic would change a saved filter's row set with no error —
152+
// `'10' > '5'` is false as text.
153+
const out = render(buildFilterClause({ ghost: { $gt: 5 } } as Filter, TABLE, []))
154+
expect(out).toContain(`(${TABLE}.data->>'ghost')::numeric`)
155+
})
156+
157+
it.each(['boolean', 'json'] as const)('still refuses a range operator on %s', (type) => {
158+
const column = LEGACY_COLUMNS.find((c) => c.type === type)
159+
if (!column) throw new Error(`no legacy column for ${type}`)
160+
expect(() => buildFilterClause({ c: { $gt: 1 } } as Filter, TABLE, [column])).toThrow(
161+
/no ordering/
162+
)
163+
})
164+
165+
it('still refuses a range operator on select and rejects a bad operand type', () => {
166+
expect(() =>
167+
buildFilterClause({ c: { $gt: 1 } } as Filter, TABLE, [LEGACY_COLUMNS[6]])
168+
).toThrow()
169+
expect(() =>
170+
buildFilterClause({ c: { $gte: 1704067200000 } } as Filter, TABLE, [LEGACY_COLUMNS[3]])
171+
).toThrow(/requires a date string, got number/)
172+
})
173+
174+
it('keeps a select option id verbatim rather than coercing it', () => {
175+
// An id of "1" coerced to the number 1 would compare against the stored
176+
// JSON string by containment and match nothing.
177+
const column: ColumnDefinition = {
178+
id: 'c',
179+
name: 'c',
180+
type: 'select',
181+
options: [{ id: '1', name: 'One' }],
182+
}
183+
const out = render(buildFilterClause({ c: { $eq: '1' } } as Filter, TABLE, [column]))
184+
expect(out).toContain('"c":"1"')
185+
})
186+
})
187+
188+
describe('sort ordering is unchanged for pre-existing types', () => {
189+
it.each([
190+
['number', '::numeric'],
191+
['date', '::timestamptz'],
192+
] as const)('sorts a %s column with %s', (type, cast) => {
193+
const column = LEGACY_COLUMNS.find((c) => c.type === type)
194+
if (!column) throw new Error(`no legacy column for ${type}`)
195+
const out = render(buildSortClause({ c: 'asc' } as Sort, TABLE, [column]))
196+
expect(out).toContain(cast)
197+
})
198+
199+
it('sorts a select column by option NAME, not by the stored id', () => {
200+
const out = render(buildSortClause({ c: 'asc' } as Sort, TABLE, [LEGACY_COLUMNS[6]]))
201+
expect(out).toContain('One')
202+
})
203+
})
204+
205+
describe('CSV import is unchanged for pre-existing shapes', () => {
206+
it.each([
207+
[['1', '2', '3'], 'number'],
208+
[['000123', '000456'], 'number'],
209+
[['true', 'false'], 'boolean'],
210+
[['2024-01-15', '2024-02-20'], 'date'],
211+
[['hello', 'world'], 'string'],
212+
[['$1,234.56', '$2.00'], 'string'],
213+
[['ada@example.com', 'bob@example.com'], 'string'],
214+
[['+1 555 123 4567', '+44 20 7123 4567'], 'string'],
215+
] as Array<[string[], string]>)('infers %s as %s', (values, expected) => {
216+
// Email and phone are deliberately NOT inferred: inference reads a 100-row
217+
// sample while the write path coerces every row and nulls what the type
218+
// rejects, so a later dirty value would be silently destroyed.
219+
expect(inferColumnType(values)).toBe(expected)
220+
})
221+
222+
it.each([
223+
['string', 'hello', 'hello'],
224+
['number', '42', 42],
225+
['boolean', 'true', true],
226+
['currency', '$1,234.56', 1234.56],
227+
] as Array<[ColumnDefinition['type'], string, JsonValue]>)(
228+
'coerces an imported %s cell to %s',
229+
(type, input, expected) => {
230+
expect(coerceValue(input, type)).toEqual(expected)
231+
}
232+
)
233+
234+
it('keeps an unparseable text cell verbatim rather than nulling it', () => {
235+
// A text-cast column preserves the raw string so the row error can name the
236+
// offending input. Only cast columns null.
237+
expect(coerceValue('not a number', 'string')).toBe('not a number')
238+
})
239+
})
240+
241+
describe('export is unchanged for pre-existing types', () => {
242+
it.each([
243+
['string', 'hello', 'hello'],
244+
['number', 1.5, '1.5'],
245+
['boolean', true, 'true'],
246+
['currency', 1234.56, '$1,234.56'],
247+
] as Array<[ColumnDefinition['type'], JsonValue, string]>)(
248+
'formats a %s cell as %s',
249+
(type, value, expected) => {
250+
const column = LEGACY_COLUMNS.find((c) => c.type === type)
251+
if (!column) throw new Error(`no legacy column for ${type}`)
252+
expect(COLUMN_TYPE_REGISTRY[type].formatForDisplay(value, column)).toBe(expected)
253+
}
254+
)
255+
256+
it('resolves a select cell to its option NAME', () => {
257+
expect(COLUMN_TYPE_REGISTRY.select.formatForDisplay('o1', LEGACY_COLUMNS[6])).toBe('One')
258+
})
259+
})

0 commit comments

Comments
 (0)