Skip to content

Commit a152eb6

Browse files
committed
feat(tables): add reference column type contract
1 parent 83c165a commit a152eb6

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
@@ -85,18 +85,21 @@ export const currencyCodeSchema = z
8585
.regex(/^[A-Za-z]{3}$/, 'Must be a 3-letter ISO 4217 currency code, e.g. USD')
8686
.overwrite((code) => code.toUpperCase())
8787

88+
export const referenceTableIdSchema = requiredFieldSchema('Reference table ID is required')
89+
8890
/**
89-
* Cross-field rule: a `select` column must declare a non-empty option set;
90-
* other types must not carry options or `multiple`, and only a `currency`
91-
* column may carry `currencyCode`. Skipped when `type` is absent (a
92-
* metadata-only update on an existing column).
91+
* Cross-field rules for type-owned metadata. A `select` column must declare a
92+
* non-empty option set, a `reference` column must declare its target table,
93+
* and type-specific fields are rejected on every type that does not own them.
94+
* Skipped when `type` is absent (a metadata-only update on an existing column).
9395
*/
9496
export function refineColumnOptions(
9597
data: {
9698
type?: (typeof COLUMN_TYPES)[number]
9799
options?: z.infer<typeof selectOptionsSchema>
98100
multiple?: boolean
99101
currencyCode?: string
102+
referenceTableId?: string
100103
},
101104
ctx: z.RefinementCtx
102105
): void {
@@ -110,6 +113,20 @@ export function refineColumnOptions(
110113
message: 'currencyCode is only allowed on currency columns',
111114
})
112115
}
116+
if (data.type !== undefined && data.type !== 'reference' && data.referenceTableId !== undefined) {
117+
ctx.addIssue({
118+
code: 'custom',
119+
path: ['referenceTableId'],
120+
message: 'referenceTableId is only allowed on reference columns',
121+
})
122+
}
123+
if (data.type === 'reference' && data.referenceTableId === undefined) {
124+
ctx.addIssue({
125+
code: 'custom',
126+
path: ['referenceTableId'],
127+
message: 'A reference column must define a reference table ID',
128+
})
129+
}
113130
if (data.type === 'select') {
114131
if (!data.options || data.options.length === 0) {
115132
ctx.addIssue({
@@ -222,6 +239,9 @@ export const tableColumnSchema = z
222239
currencyCode: currencyCodeSchema
223240
.optional()
224241
.describe('ISO 4217 code for a currency column, normalized to uppercase.'),
242+
referenceTableId: referenceTableIdSchema
243+
.optional()
244+
.describe('Target table whose row IDs are stored by a reference column.'),
225245
})
226246
.superRefine(refineColumnOptions)
227247
.describe('A typed column in a table schema.')
@@ -304,6 +324,9 @@ export const createTableColumnBodySchema = z.object({
304324
options: selectOptionsSchema.optional().describe('Options for a select column.'),
305325
multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'),
306326
currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for a currency column.'),
327+
referenceTableId: referenceTableIdSchema
328+
.optional()
329+
.describe('Target table for a reference column.'),
307330
})
308331
.superRefine(refineColumnOptions)
309332
.describe('Typed column definition to add.'),
@@ -321,6 +344,9 @@ export const updateTableColumnBodySchema = z.object({
321344
options: selectOptionsSchema.optional().describe('Replacement select options.'),
322345
multiple: z.boolean().optional().describe('New multi-select setting.'),
323346
currencyCode: currencyCodeSchema.optional().describe('New ISO 4217 currency code.'),
347+
referenceTableId: referenceTableIdSchema
348+
.optional()
349+
.describe('New target table for a reference column.'),
324350
})
325351
.superRefine(refineColumnOptions)
326352
.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 type { ColumnType } from '@/lib/table/column-types'
1415
import {
@@ -42,6 +43,17 @@ describe('registry shape', () => {
4243
expect(isColumnType('currency')).toBe(true)
4344
})
4445

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

192204
it.each`
193-
label | definition | valid | needle
194-
${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''}
195-
${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'}
196-
${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'}
197-
${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'}
198-
${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''}
199-
${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'}
200-
${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'}
201-
${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'}
202-
${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'}
203-
${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''}
204-
${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'}
205-
${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'}
205+
label | definition | valid | needle
206+
${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''}
207+
${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'}
208+
${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'}
209+
${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'}
210+
${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''}
211+
${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'}
212+
${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'}
213+
${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'}
214+
${'target on reference'} | ${column({ type: 'reference', referenceTableId: 'tbl_anything' })} | ${true} | ${''}
215+
${'missing target'} | ${column({ type: 'reference' })} | ${false} | ${'reference table'}
216+
${'empty target'} | ${column({ type: 'reference', referenceTableId: '' })} | ${false} | ${'reference table'}
217+
${'target on string'} | ${column({ type: 'string', referenceTableId: 'tbl_other' })} | ${false} | ${'reference another table'}
218+
${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'}
219+
${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''}
220+
${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'}
221+
${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'}
206222
`(
207223
'rejects $label',
208224
({
@@ -219,6 +235,23 @@ describe('metadata ownership', () => {
219235
if (!valid) expect(result.errors.join(' ').toLowerCase()).toContain(needle.toLowerCase())
220236
}
221237
)
238+
239+
it('accepts arbitrary row-id strings without resolving them', () => {
240+
const column = {
241+
name: 'account',
242+
type: 'reference',
243+
referenceTableId: 'tbl_accounts',
244+
} as ColumnDefinition
245+
const definition = COLUMN_TYPE_REGISTRY.reference
246+
247+
expect(definition.coerce('not-a-real-row-id', column)).toEqual({
248+
ok: true,
249+
value: 'not-a-real-row-id',
250+
})
251+
expect(definition.coerce(97, column)).toEqual({ ok: true, value: '97' })
252+
expect(definition.coerce(true, column)).toEqual({ ok: true, value: 'true' })
253+
expect(definition.validateCell('not-a-real-row-id', column)).toBeNull()
254+
})
222255
})
223256

224257
/**
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
@@ -288,6 +288,7 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record<ColumnType, ColumnTypeServerEnt
288288
migrateSelectCellsToNames(trx, tableId, workspaceId, columnKey, previous.options ?? []),
289289
},
290290
currency: COLUMN_TYPE_REGISTRY.currency,
291+
reference: COLUMN_TYPE_REGISTRY.reference,
291292
}
292293

293294
/** 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

@@ -108,9 +110,8 @@ export function validateTypeMetadata(column: ColumnDefinition): string[] {
108110
* A column's type-specific metadata, as a spreadable object.
109111
*
110112
* Callers that copy a column — the API response serializer, the undo snapshot —
111-
* used to name `options`/`multiple`/`currencyCode` by hand, so a new type's
112-
* metadata was stored but silently dropped on the way out. Reading the key list
113-
* keeps them zero-edit.
113+
* used to name type-specific keys by hand, so a new type's metadata was stored
114+
* but silently dropped on the way out. Reading the key list keeps them zero-edit.
114115
*/
115116
export function typeMetadataOf(column: ColumnDefinition): Partial<ColumnDefinition> {
116117
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
@@ -39,6 +39,7 @@ export const COLUMN_TYPES = [
3939
'ttl',
4040
'json',
4141
'select',
42+
'reference',
4243
] as const
4344

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

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

‎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)