Skip to content

Commit bb174af

Browse files
committed
feat(tables): add reference column type contract
1 parent 65a58a8 commit bb174af

13 files changed

Lines changed: 232 additions & 22 deletions

File tree

apps/sim/lib/api/contracts/tables.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,49 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { tableEventStreamQuerySchema, tableRowsQuerySchema } from '@/lib/api/contracts/tables'
5+
import {
6+
createTableColumnBodySchema,
7+
tableColumnSchema,
8+
tableEventStreamQuerySchema,
9+
tableRowsQuerySchema,
10+
updateTableColumnBodySchema,
11+
} from '@/lib/api/contracts/tables'
12+
13+
describe('reference column metadata', () => {
14+
const referenceColumn = {
15+
name: 'account',
16+
type: 'reference',
17+
referenceTableId: 'tbl_accounts',
18+
}
19+
20+
it('preserves the target table id in every HTTP column schema', () => {
21+
expect(tableColumnSchema.parse(referenceColumn).referenceTableId).toBe('tbl_accounts')
22+
expect(
23+
createTableColumnBodySchema.parse({
24+
workspaceId: 'ws-1',
25+
column: referenceColumn,
26+
}).column.referenceTableId
27+
).toBe('tbl_accounts')
28+
expect(
29+
updateTableColumnBodySchema.parse({
30+
workspaceId: 'ws-1',
31+
columnName: 'account',
32+
updates: { referenceTableId: 'tbl_other' },
33+
}).updates.referenceTableId
34+
).toBe('tbl_other')
35+
})
36+
37+
it('requires a non-empty target for reference columns', () => {
38+
expect(tableColumnSchema.safeParse({ name: 'account', type: 'reference' }).success).toBe(false)
39+
expect(tableColumnSchema.safeParse({ ...referenceColumn, referenceTableId: '' }).success).toBe(
40+
false
41+
)
42+
})
43+
44+
it('rejects reference metadata on another column type', () => {
45+
expect(tableColumnSchema.safeParse({ ...referenceColumn, type: 'string' }).success).toBe(false)
46+
})
47+
})
648

749
/**
850
* `requestJson` parses the query through this schema on the CLIENT before building the URL, so

apps/sim/lib/api/contracts/tables.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,21 @@ export const currencyCodeSchema = z
8383
.regex(/^[A-Za-z]{3}$/, 'Must be a 3-letter ISO 4217 currency code, e.g. USD')
8484
.overwrite((code) => code.toUpperCase())
8585

86+
export const referenceTableIdSchema = requiredFieldSchema('Reference table ID is required')
87+
8688
/**
87-
* Cross-field rule: a `select` column must declare a non-empty option set;
88-
* other types must not carry options or `multiple`, and only a `currency`
89-
* column may carry `currencyCode`. Skipped when `type` is absent (a
90-
* metadata-only update on an existing column).
89+
* Cross-field rules for type-owned metadata. A `select` column must declare a
90+
* non-empty option set, a `reference` column must declare its target table,
91+
* and type-specific fields are rejected on every type that does not own them.
92+
* Skipped when `type` is absent (a metadata-only update on an existing column).
9193
*/
9294
export function refineColumnOptions(
9395
data: {
9496
type?: (typeof COLUMN_TYPES)[number]
9597
options?: z.infer<typeof selectOptionsSchema>
9698
multiple?: boolean
9799
currencyCode?: string
100+
referenceTableId?: string
98101
},
99102
ctx: z.RefinementCtx
100103
): void {
@@ -108,6 +111,20 @@ export function refineColumnOptions(
108111
message: 'currencyCode is only allowed on currency columns',
109112
})
110113
}
114+
if (data.type !== undefined && data.type !== 'reference' && data.referenceTableId !== undefined) {
115+
ctx.addIssue({
116+
code: 'custom',
117+
path: ['referenceTableId'],
118+
message: 'referenceTableId is only allowed on reference columns',
119+
})
120+
}
121+
if (data.type === 'reference' && data.referenceTableId === undefined) {
122+
ctx.addIssue({
123+
code: 'custom',
124+
path: ['referenceTableId'],
125+
message: 'A reference column must define a reference table ID',
126+
})
127+
}
111128
if (data.type === 'select') {
112129
if (!data.options || data.options.length === 0) {
113130
ctx.addIssue({
@@ -220,6 +237,9 @@ export const tableColumnSchema = z
220237
currencyCode: currencyCodeSchema
221238
.optional()
222239
.describe('ISO 4217 code for a currency column, normalized to uppercase.'),
240+
referenceTableId: referenceTableIdSchema
241+
.optional()
242+
.describe('Target table whose row IDs are stored by a reference column.'),
223243
})
224244
.superRefine(refineColumnOptions)
225245
.describe('A typed column in a table schema.')
@@ -302,6 +322,9 @@ export const createTableColumnBodySchema = z.object({
302322
options: selectOptionsSchema.optional().describe('Options for a select column.'),
303323
multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'),
304324
currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for a currency column.'),
325+
referenceTableId: referenceTableIdSchema
326+
.optional()
327+
.describe('Target table for a reference column.'),
305328
})
306329
.superRefine(refineColumnOptions)
307330
.describe('Typed column definition to add.'),
@@ -319,6 +342,9 @@ export const updateTableColumnBodySchema = z.object({
319342
options: selectOptionsSchema.optional().describe('Replacement select options.'),
320343
multiple: z.boolean().optional().describe('New multi-select setting.'),
321344
currencyCode: currencyCodeSchema.optional().describe('New ISO 4217 currency code.'),
345+
referenceTableId: referenceTableIdSchema
346+
.optional()
347+
.describe('New target table for a reference column.'),
322348
})
323349
.superRefine(refineColumnOptions)
324350
.describe('Column fields to update.'),

apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,37 @@ import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
4545
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
4646

4747
describe('v2 table column contracts', () => {
48+
it('preserves reference table metadata on every public column write', () => {
49+
expect(
50+
v2CreateTableBodySchema.safeParse({
51+
workspaceId: WORKSPACE_ID,
52+
name: 'contacts',
53+
schema: {
54+
columns: [{ name: 'account', type: 'reference', referenceTableId: 'tbl_accounts' }],
55+
},
56+
})
57+
).toMatchObject({
58+
success: true,
59+
data: { schema: { columns: [{ referenceTableId: 'tbl_accounts' }] } },
60+
})
61+
expect(
62+
v2CreateTableColumnBodySchema.safeParse({
63+
workspaceId: WORKSPACE_ID,
64+
column: { name: 'account', type: 'reference', referenceTableId: 'tbl_accounts' },
65+
})
66+
).toMatchObject({
67+
success: true,
68+
data: { column: { referenceTableId: 'tbl_accounts' } },
69+
})
70+
expect(
71+
v2UpdateTableColumnBodySchema.safeParse({
72+
workspaceId: WORKSPACE_ID,
73+
columnName: 'account',
74+
updates: { referenceTableId: 'tbl_other' },
75+
})
76+
).toMatchObject({ success: true, data: { updates: { referenceTableId: 'tbl_other' } } })
77+
})
78+
4879
it('accepts required on every public column write so a column round-trips', () => {
4980
expect(
5081
v2CreateTableBodySchema.safeParse({

apps/sim/lib/api/contracts/v2/tables.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
insertTableRowBodyBaseSchema,
1818
predicateInputSchema,
1919
predicateSchema,
20+
referenceTableIdSchema,
2021
refineCancelTableRunsScope,
2122
refineColumnOptions,
2223
rowAnchorMutexRefine,
@@ -472,6 +473,9 @@ const v2TableColumnInputShape = {
472473
options: selectOptionsSchema.optional().describe('Select options for select-type columns.'),
473474
multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'),
474475
currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for currency columns.'),
476+
referenceTableId: referenceTableIdSchema
477+
.optional()
478+
.describe('Target table for reference columns.'),
475479
}
476480

477481
/**
@@ -770,6 +774,9 @@ export const v2UpdateTableColumnBodySchema = z
770774
currencyCode: currencyCodeSchema
771775
.optional()
772776
.describe('Replacement ISO 4217 code for a currency column.'),
777+
referenceTableId: referenceTableIdSchema
778+
.optional()
779+
.describe('Replacement target table for a reference column.'),
773780
})
774781
.strict()
775782
.superRefine(refineColumnOptions)

apps/sim/lib/table/__tests__/column-type-registry.test.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* be spread across those arms, so a new type either satisfies them or fails
1010
* here.
1111
*/
12+
import { Table as TableIcon } from '@sim/emcn/icons'
1213
import { describe, expect, it } from 'vitest'
1314
import { zonedWallClockToUtc } from '@/lib/core/utils/timezone'
1415
import type { ColumnType } from '@/lib/table/column-types'
@@ -43,6 +44,17 @@ describe('registry shape', () => {
4344
expect(isColumnType('currency')).toBe(true)
4445
})
4546

47+
it('registers reference columns as configured string-backed columns', () => {
48+
const definition = COLUMN_TYPE_REGISTRY.reference
49+
50+
expect(definition.label).toBe('Reference')
51+
expect(definition.icon).toBe(TableIcon)
52+
expect(definition.requiresConfigurationOnCreate).toBe(true)
53+
expect(definition.hasConfiguration).toBe(true)
54+
expect(definition.ownedMetadata).toEqual(['referenceTableId'])
55+
expect(definition.jsonbCast).toBeNull()
56+
})
57+
4658
it('only casts to numeric/timestamptz for types whose storage is actually that', () => {
4759
// A wrong cast makes every filter and sort on the column fail in SQL.
4860
for (const definition of ALL_COLUMN_TYPES) {
@@ -317,19 +329,23 @@ describe('metadata ownership', () => {
317329
const options = [{ id: 'opt_a', name: 'A' }]
318330

319331
it.each`
320-
label | definition | valid | needle
321-
${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''}
322-
${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'}
323-
${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'}
324-
${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'}
325-
${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''}
326-
${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'}
327-
${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'}
328-
${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'}
329-
${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'}
330-
${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''}
331-
${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'}
332-
${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'}
332+
label | definition | valid | needle
333+
${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''}
334+
${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'}
335+
${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'}
336+
${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'}
337+
${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''}
338+
${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'}
339+
${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'}
340+
${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'}
341+
${'target on reference'} | ${column({ type: 'reference', referenceTableId: 'tbl_anything' })} | ${true} | ${''}
342+
${'missing target'} | ${column({ type: 'reference' })} | ${false} | ${'reference table'}
343+
${'empty target'} | ${column({ type: 'reference', referenceTableId: '' })} | ${false} | ${'reference table'}
344+
${'target on string'} | ${column({ type: 'string', referenceTableId: 'tbl_other' })} | ${false} | ${'reference another table'}
345+
${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'}
346+
${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''}
347+
${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'}
348+
${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'}
333349
`(
334350
'rejects $label',
335351
({
@@ -346,6 +362,23 @@ describe('metadata ownership', () => {
346362
if (!valid) expect(result.errors.join(' ').toLowerCase()).toContain(needle.toLowerCase())
347363
}
348364
)
365+
366+
it('accepts arbitrary row-id strings without resolving them', () => {
367+
const column = {
368+
name: 'account',
369+
type: 'reference',
370+
referenceTableId: 'tbl_accounts',
371+
} as ColumnDefinition
372+
const definition = COLUMN_TYPE_REGISTRY.reference
373+
374+
expect(definition.coerce('not-a-real-row-id', column)).toEqual({
375+
ok: true,
376+
value: 'not-a-real-row-id',
377+
})
378+
expect(definition.coerce(97, column)).toEqual({ ok: true, value: '97' })
379+
expect(definition.coerce(true, column)).toEqual({ ok: true, value: 'true' })
380+
expect(definition.validateCell('not-a-real-row-id', column)).toBeNull()
381+
})
349382
})
350383

351384
/**
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Table as TableIcon } from '@sim/emcn/icons'
2+
import type { ColumnTypeDefinition } from '@/lib/table/column-types/types'
3+
4+
export const referenceColumnType: ColumnTypeDefinition = {
5+
id: 'reference',
6+
label: 'Reference',
7+
icon: TableIcon,
8+
jsonbCast: null,
9+
storesOpaqueIds: false,
10+
supportsUnique: true,
11+
requiresConfigurationOnCreate: true,
12+
hasConfiguration: true,
13+
sampleValue: 'row_123',
14+
ownedMetadata: ['referenceTableId'],
15+
workflowInputType: 'string',
16+
editor: 'text',
17+
expandable: false,
18+
19+
coerce(value) {
20+
if (typeof value === 'string') return { ok: true, value }
21+
if (typeof value === 'number' || typeof value === 'boolean') {
22+
return { ok: true, value: String(value) }
23+
}
24+
return { ok: false }
25+
},
26+
27+
validateCell(value, column) {
28+
return typeof value === 'string' ? null : `${column.name} must be a row ID string`
29+
},
30+
31+
validateDefinition(column) {
32+
if (typeof column.referenceTableId !== 'string' || column.referenceTableId.length === 0) {
33+
return [`Column "${column.name}" must define a reference table ID`]
34+
}
35+
return []
36+
},
37+
38+
formatForDisplay(value) {
39+
if (typeof value === 'string') return value
40+
if (value === null || value === undefined) return ''
41+
return typeof value === 'object' ? JSON.stringify(value) : String(value)
42+
},
43+
44+
formatForInput(value) {
45+
if (typeof value === 'object') return JSON.stringify(value)
46+
return String(value)
47+
},
48+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record<ColumnType, ColumnTypeServerEnt
290290
migrateSelectCellsToNames(trx, tableId, workspaceId, columnKey, previous.options ?? []),
291291
},
292292
currency: COLUMN_TYPE_REGISTRY.currency,
293+
reference: COLUMN_TYPE_REGISTRY.reference,
293294
}
294295

295296
/** The inbound migration for a target type, if it has one. */

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { currencyColumnType } from '@/lib/table/column-types/currency'
2424
import { dateColumnType } from '@/lib/table/column-types/date'
2525
import { jsonColumnType } from '@/lib/table/column-types/json'
2626
import { numberColumnType } from '@/lib/table/column-types/number'
27+
import { referenceColumnType } from '@/lib/table/column-types/reference'
2728
import {
2829
MULTI_SELECT_OPERATORS,
2930
MULTI_SELECT_OPS,
@@ -52,6 +53,7 @@ export const COLUMN_TYPE_REGISTRY: Record<ColumnType, ColumnTypeDefinition> = {
5253
ttl: ttlColumnType,
5354
json: jsonColumnType,
5455
select: selectColumnType,
56+
reference: referenceColumnType,
5557
currency: currencyColumnType,
5658
}
5759

@@ -113,9 +115,8 @@ export function validateTypeMetadata(column: ColumnDefinition): string[] {
113115
* A column's type-specific metadata, as a spreadable object.
114116
*
115117
* Callers that copy a column — the API response serializer, the undo snapshot —
116-
* used to name `options`/`multiple`/`currencyCode` by hand, so a new type's
117-
* metadata was stored but silently dropped on the way out. Reading the key list
118-
* keeps them zero-edit.
118+
* used to name type-specific keys by hand, so a new type's metadata was stored
119+
* but silently dropped on the way out. Reading the key list keeps them zero-edit.
119120
*/
120121
export function typeMetadataOf(column: ColumnDefinition): Partial<ColumnDefinition> {
121122
const metadata: Partial<ColumnDefinition> = {}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const COLUMN_TYPES = [
4040
'ttl',
4141
'json',
4242
'select',
43+
'reference',
4344
] as const
4445

4546
export type ColumnType = (typeof COLUMN_TYPES)[number]
@@ -62,7 +63,12 @@ export type ColumnCellEditor =
6263
* means extending this list and that type's `ownedMetadata` — not editing the
6364
* validator.
6465
*/
65-
export const TYPE_SPECIFIC_COLUMN_KEYS = ['options', 'multiple', 'currencyCode'] as const
66+
export const TYPE_SPECIFIC_COLUMN_KEYS = [
67+
'options',
68+
'multiple',
69+
'currencyCode',
70+
'referenceTableId',
71+
] as const
6672

6773
export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number]
6874

apps/sim/lib/table/import.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ describe('import', () => {
164164
expect(coerceValue('yes', 'boolean')).toBeNull()
165165
})
166166

167+
it('keeps imported reference values as row-id strings', () => {
168+
expect(coerceValue('row_external_123', 'reference')).toBe('row_external_123')
169+
expect(coerceValue(97, 'reference')).toBe('97')
170+
})
171+
167172
it('keeps date-only values as calendar dates, preserves datetime wall times with their offset, and falls back to the original string', () => {
168173
expect(coerceValue('2024-01-01', 'date')).toBe('2024-01-01')
169174
expect(coerceValue('2024-01-01T12:30:00-07:00', 'date')).toBe('2024-01-01T12:30:00-07:00')

0 commit comments

Comments
 (0)