Skip to content

Commit 13868b9

Browse files
committed
fix(tables): normalize contact filter values, keep clears through a retype
Greptile's P1: an equality filter on an Email or Phone column matched nothing. `parseFilterScalar` gated type-aware parsing on `jsonbCast !== null`, but that asks how the comparison is PERFORMED, not whether the stored form differs from what the user typed. Email and Phone canonicalize on write and compare as text, so they were skipped — a filter for `Ada@Example.com` was accepted and then silently missed the stored `ada@example.com`, likewise `020 1234 5678` against `+442071234567`. Types now declare `canonicalizesValues`, which is the question the filter actually needs answered. Second: a type change carrying `precision: null` dropped the clear. The routes stripped nulls before `updateColumnType`, and `buildConvertedColumn` carries un-supplied keys forward from the old column — so the stripped null read as "not mentioned" and restored the very setting the user had cleared. Clears now reach the retype, where absent and cleared are finally distinguishable. `metadataWithoutClears` keeps serving the create and validate paths, which have nothing to remove; its comment claiming the retype rebuilt from scratch was simply wrong. `buildConvertedColumn` is exported for the test, matching the three helpers this module already exports for the same reason.
1 parent bae3d33 commit 13868b9

19 files changed

Lines changed: 155 additions & 41 deletions

File tree

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

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,7 @@ import {
2020
updateColumnType,
2121
} from '@/lib/table'
2222
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
23-
import {
24-
columnTypeById,
25-
metadataKeysIn,
26-
metadataWithoutClears,
27-
pickMetadata,
28-
} from '@/lib/table/column-types'
23+
import { columnTypeById, metadataKeysIn, pickMetadata } from '@/lib/table/column-types'
2924
import { validateMetadataUpdate } from '@/lib/table/columns/metadata'
3025
import { signalTableSchemaChanged } from '@/lib/table/events'
3126
import {
@@ -220,10 +215,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
220215
newType: updates.type as NonNullable<typeof updates.type>,
221216
// Every type-specific key the payload carries, whichever writer would
222217
// own it standalone: a conversion applies its target's metadata in the
223-
// same transaction rather than leaving it to a second write.
224-
...metadataWithoutClears(
225-
pickMetadata(updates, [...genericMetadataKeys, ...dedicatedMetadataKeys])
226-
),
218+
// same transaction rather than leaving it to a second write. Clears
219+
// are forwarded as `null` — stripping them here made
220+
// `buildConvertedColumn` fall back to the pre-conversion value.
221+
...pickMetadata(updates, [...genericMetadataKeys, ...dedicatedMetadataKeys]),
227222
// Forwarded so the conversion validates against the constraint this
228223
// same request is about to set, not the column's current one.
229224
...(updates.required !== undefined ? { required: updates.required } : {}),

apps/sim/app/api/v1/tables/[tableId]/columns/route.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,7 @@ import {
1919
updateColumnType,
2020
} from '@/lib/table'
2121
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
22-
import {
23-
columnTypeById,
24-
metadataKeysIn,
25-
metadataWithoutClears,
26-
pickMetadata,
27-
} from '@/lib/table/column-types'
22+
import { columnTypeById, metadataKeysIn, pickMetadata } from '@/lib/table/column-types'
2823
import { validateMetadataUpdate } from '@/lib/table/columns/metadata'
2924
import { signalTableSchemaChanged } from '@/lib/table/events'
3025
import {
@@ -254,10 +249,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
254249
newType: updates.type as NonNullable<typeof updates.type>,
255250
// Every type-specific key the payload carries, whichever writer would
256251
// own it standalone: a conversion applies its target's metadata in the
257-
// same transaction rather than leaving it to a second write.
258-
...metadataWithoutClears(
259-
pickMetadata(updates, [...genericMetadataKeys, ...dedicatedMetadataKeys])
260-
),
252+
// same transaction rather than leaving it to a second write. Clears
253+
// are forwarded as `null` — stripping them here made
254+
// `buildConvertedColumn` fall back to the pre-conversion value.
255+
...pickMetadata(updates, [...genericMetadataKeys, ...dedicatedMetadataKeys]),
261256
// Forwarded so the conversion validates against the constraint this
262257
// same request is about to set, not the column's current one.
263258
...(updates.required !== undefined ? { required: updates.required } : {}),

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-na
3737
import {
3838
columnTypeById,
3939
metadataKeysIn,
40-
metadataWithoutClears,
4140
pickMetadata,
4241
TYPE_SPECIFIC_COLUMN_KEYS,
4342
validateTypeMetadata,
@@ -1788,7 +1787,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
17881787
newType: newType as (typeof COLUMN_TYPES)[number],
17891788
options,
17901789
multiple,
1791-
...metadataWithoutClears(pickMetadata(metadataUpdates, genericMetadataKeys)),
1790+
...pickMetadata(metadataUpdates, genericMetadataKeys),
17921791
...(uniqFlag !== undefined ? { unique: uniqFlag } : {}),
17931792
},
17941793
requestId

apps/sim/lib/table/__tests__/column-types-contact.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ownersOfMetadataKey,
2020
pickMetadata,
2121
} from '@/lib/table/column-types'
22+
import { buildConvertedColumn } from '@/lib/table/columns/service'
2223
import { coerceValue } from '@/lib/table/import'
2324
import { filterRulesToFilter, prunePredicateForColumns } from '@/lib/table/query-builder/converters'
2425
import type { ColumnDefinition } from '@/lib/table/types'
@@ -324,6 +325,90 @@ describe('review-round-4 regressions', () => {
324325
})
325326
})
326327

328+
describe('review-round-7 regressions', () => {
329+
it('normalizes a contact filter value so it can match the stored form', () => {
330+
// Email/Phone canonicalize on write but compare as TEXT, so the earlier
331+
// `jsonbCast !== null` gate skipped them: the filter was accepted and then
332+
// matched nothing, because JSONB containment saw `Ada@Example.com` against
333+
// a stored `ada@example.com`.
334+
const eq = (value: string, col: ColumnDefinition) =>
335+
filterRulesToFilter(
336+
[{ id: 'r1', logicalOperator: 'and' as const, column: 'c', operator: 'eq', value }],
337+
[{ ...col, id: 'c' }]
338+
)
339+
expect(eq('Ada@Example.COM', column('email'))).toEqual({ c: 'ada@example.com' })
340+
expect(eq('+44 20 7123 4567', column('phone'))).toEqual({ c: '+442071234567' })
341+
expect(eq('(555) 123-4567', column('phone'))).toEqual({ c: '5551234567' })
342+
})
343+
344+
it('leaves a pass-through type on its existing filter coercion', () => {
345+
const eq = (value: string, col: ColumnDefinition) =>
346+
filterRulesToFilter(
347+
[{ id: 'r1', logicalOperator: 'and' as const, column: 'c', operator: 'eq', value }],
348+
[{ ...col, id: 'c' }]
349+
)
350+
expect(eq('123', column('string'))).toEqual({ c: 123 })
351+
expect(eq('1', column('select', { options: [{ id: '1', name: 'One' }] }))).toEqual({ c: '1' })
352+
})
353+
354+
it('declares canonicalization only where coerce actually transforms', () => {
355+
// The property stated directly: a canonicalizing type turns a valid
356+
// non-canonical input into something DIFFERENT, and a pass-through type
357+
// returns it unchanged. Asserting the behaviour rather than the flag is
358+
// what stops the two drifting.
359+
const transforms: Array<[ColumnDefinition['type'], string, JsonValue]> = [
360+
['email', 'Ada@Example.COM', 'ada@example.com'],
361+
['phone', '(555) 123-4567', '5551234567'],
362+
['currency', '$1,234.56', 1234.56],
363+
['number', '42', 42],
364+
['percent', '50%', 50],
365+
['date', '01/15/2024', '2024-01-15'],
366+
]
367+
for (const [type, input, expected] of transforms) {
368+
expect(COLUMN_TYPE_REGISTRY[type].canonicalizesValues, type).toBe(true)
369+
const result = COLUMN_TYPE_REGISTRY[type].coerce(input, column(type))
370+
expect(result.ok && result.value, `${type} <- ${input}`).toEqual(expected)
371+
}
372+
373+
for (const type of ['string', 'json'] as const) {
374+
expect(COLUMN_TYPE_REGISTRY[type].canonicalizesValues, type).toBe(false)
375+
}
376+
// A pass-through type hands the value straight back.
377+
const passed = COLUMN_TYPE_REGISTRY.string.coerce('Ada@Example.COM', column('string'))
378+
expect(passed.ok && passed.value).toBe('Ada@Example.COM')
379+
})
380+
381+
it('keeps an explicit metadata clear through a type conversion', () => {
382+
// `buildConvertedColumn` carries UN-SUPPLIED keys forward from the old
383+
// column, so an explicit clear must be distinguishable from silence. When
384+
// the route stripped the null first, this read as "not mentioned" and
385+
// restored the precision the user had just cleared.
386+
const from: ColumnDefinition = { name: 'c', type: 'percent', precision: 2 }
387+
const cleared = buildConvertedColumn(
388+
from,
389+
{ tableId: 't', columnName: 'c', newType: 'number', precision: null },
390+
{ isSelectType: false, targetMultiple: false }
391+
)
392+
expect(cleared.precision).toBeUndefined()
393+
394+
// Silence still carries the old value across the conversion.
395+
const carried = buildConvertedColumn(
396+
from,
397+
{ tableId: 't', columnName: 'c', newType: 'number' },
398+
{ isSelectType: false, targetMultiple: false }
399+
)
400+
expect(carried.precision).toBe(2)
401+
402+
// And an explicit new value still wins.
403+
const replaced = buildConvertedColumn(
404+
from,
405+
{ tableId: 't', columnName: 'c', newType: 'number', precision: 4 },
406+
{ isSelectType: false, targetMultiple: false }
407+
)
408+
expect(replaced.precision).toBe(4)
409+
})
410+
})
411+
327412
describe('date includeTime', () => {
328413
it('truncates to a calendar day only when includeTime is explicitly false', () => {
329414
const dateOnly = column('date', { includeTime: false })

apps/sim/lib/table/column-types/boolean.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const booleanColumnType: ColumnTypeDefinition = {
77
label: 'Boolean',
88
icon: TypeBoolean,
99
jsonbCast: null,
10+
canonicalizesValues: false,
1011
orderable: false,
1112
storesOpaqueIds: false,
1213
supportsUnique: true,

apps/sim/lib/table/column-types/currency.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const currencyColumnType: ColumnTypeDefinition = {
1414
label: 'Currency',
1515
icon: TypeCurrency,
1616
jsonbCast: 'numeric',
17+
canonicalizesValues: true,
1718
orderable: true,
1819
storesOpaqueIds: false,
1920
supportsUnique: true,

apps/sim/lib/table/column-types/date.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const dateColumnType: ColumnTypeDefinition = {
4646
label: 'Date',
4747
icon: CalendarIcon,
4848
jsonbCast: 'timestamptz',
49+
canonicalizesValues: true,
4950
orderable: true,
5051
storesOpaqueIds: false,
5152
supportsUnique: true,

apps/sim/lib/table/column-types/email.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const emailColumnType: ColumnTypeDefinition = {
7777
icon: TypeEmail,
7878
// Stored as plain text; comparison and sorting are lexical, like `string`.
7979
jsonbCast: null,
80+
canonicalizesValues: true,
8081
orderable: true,
8182
storesOpaqueIds: false,
8283
supportsUnique: true,

apps/sim/lib/table/column-types/json.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const jsonColumnType: ColumnTypeDefinition = {
77
label: 'JSON',
88
icon: TypeJson,
99
jsonbCast: null,
10+
canonicalizesValues: false,
1011
orderable: false,
1112
storesOpaqueIds: false,
1213
supportsUnique: true,

apps/sim/lib/table/column-types/number.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const numberColumnType: ColumnTypeDefinition = {
88
label: 'Number',
99
icon: TypeNumber,
1010
jsonbCast: 'numeric',
11+
canonicalizesValues: true,
1112
orderable: true,
1213
storesOpaqueIds: false,
1314
supportsUnique: true,

0 commit comments

Comments
 (0)