Skip to content

Commit 32b9566

Browse files
j15zclaude
andcommitted
feat(tables): add per-column-type behavior registry
Each column type's SQL cast, parse, validate, format, and conversion- compatibility rules were implemented as independent switch statements scattered across sql.ts, validation.ts, and columns/service.ts. Adding a type meant hand-updating every switch, and a missed one failed silently and differently depending which switch it was. column-types.ts is the single per-type definition those switches will delegate to, keyed by a Record typed against COLUMN_TYPES so a missing entry is a compile error instead of a runtime surprise. select-values.ts gains resolveSelectOptionId/splitMultiSelectInput (moved here rather than importing validation.ts, which pulls in @sim/db and would taint or cycle with this otherwise-pure module) so the registry's select behavior has a shared, pure home to depend on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1a23438 commit 32b9566

5 files changed

Lines changed: 726 additions & 3 deletions

File tree

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
booleanColumnType,
7+
COLUMN_TYPE_REGISTRY,
8+
dateColumnType,
9+
formatColumnValue,
10+
getColumnType,
11+
isValidColumnValue,
12+
isValueCompatibleWithColumnType,
13+
jsonColumnType,
14+
numberColumnType,
15+
parseColumnValue,
16+
selectColumnType,
17+
sqlCastForColumnType,
18+
stringColumnType,
19+
} from '@/lib/table/column-types'
20+
import { COLUMN_TYPES } from '@/lib/table/constants'
21+
import type { ColumnDefinition, JsonValue } from '@/lib/table/types'
22+
23+
const stringCol: ColumnDefinition = { name: 'title', type: 'string' }
24+
const numberCol: ColumnDefinition = { name: 'price', type: 'number' }
25+
const booleanCol: ColumnDefinition = { name: 'active', type: 'boolean' }
26+
const dateCol: ColumnDefinition = { name: 'due', type: 'date' }
27+
const jsonCol: ColumnDefinition = { name: 'payload', type: 'json' }
28+
29+
const status: ColumnDefinition = {
30+
name: 'status',
31+
type: 'select',
32+
options: [
33+
{ id: 'opt_open', name: 'Open' },
34+
{ id: 'opt_closed', name: 'Closed' },
35+
],
36+
}
37+
38+
const tags: ColumnDefinition = {
39+
name: 'tags',
40+
type: 'select',
41+
multiple: true,
42+
options: [
43+
{ id: 'opt_a', name: 'Alpha' },
44+
{ id: 'opt_b', name: 'Beta' },
45+
],
46+
}
47+
48+
describe('COLUMN_TYPE_REGISTRY', () => {
49+
it('has a definition for every entry in COLUMN_TYPES', () => {
50+
for (const type of COLUMN_TYPES) {
51+
expect(COLUMN_TYPE_REGISTRY[type]).toBeDefined()
52+
}
53+
})
54+
})
55+
56+
describe('getColumnType', () => {
57+
it('resolves a known type', () => {
58+
expect(getColumnType('number')).toBe(numberColumnType)
59+
})
60+
61+
it('returns undefined for an unrecognized type', () => {
62+
expect(getColumnType('currency')).toBeUndefined()
63+
})
64+
})
65+
66+
describe('stringColumnType', () => {
67+
it('sqlCast: compares as text', () => {
68+
expect(stringColumnType.sqlCast).toBeNull()
69+
})
70+
71+
it('parse: accepts strings, stringifies numbers/booleans, rejects objects', () => {
72+
expect(stringColumnType.parse('hello', stringCol)).toEqual({ ok: true, value: 'hello' })
73+
expect(stringColumnType.parse(42, stringCol)).toEqual({ ok: true, value: '42' })
74+
expect(stringColumnType.parse(true, stringCol)).toEqual({ ok: true, value: 'true' })
75+
expect(stringColumnType.parse(['a'], stringCol)).toEqual({ ok: false })
76+
})
77+
78+
it('isValidValue: only a string passes', () => {
79+
expect(stringColumnType.isValidValue('hello', stringCol)).toBeNull()
80+
expect(stringColumnType.isValidValue(42, stringCol)).toBe('title must be string, got number')
81+
})
82+
83+
it('format: returns the string as-is', () => {
84+
expect(stringColumnType.format('hello', stringCol)).toBe('hello')
85+
})
86+
87+
it('isCompatible: rejects objects and arrays, accepts everything else', () => {
88+
expect(stringColumnType.isCompatible('42', { type: 'string' })).toBe(true)
89+
expect(stringColumnType.isCompatible(42, { type: 'string' })).toBe(true)
90+
expect(stringColumnType.isCompatible(['a'], { type: 'string' })).toBe(false)
91+
expect(stringColumnType.isCompatible({ a: 1 }, { type: 'string' })).toBe(false)
92+
})
93+
})
94+
95+
describe('numberColumnType', () => {
96+
it('sqlCast: numeric', () => {
97+
expect(numberColumnType.sqlCast).toBe('numeric')
98+
})
99+
100+
it('parse: accepts finite numbers and numeric strings', () => {
101+
expect(numberColumnType.parse(42, numberCol)).toEqual({ ok: true, value: 42 })
102+
expect(numberColumnType.parse('42', numberCol)).toEqual({ ok: true, value: 42 })
103+
expect(numberColumnType.parse(Number.POSITIVE_INFINITY, numberCol)).toEqual({ ok: false })
104+
})
105+
106+
it('parse: treats a whitespace-only string as empty rather than 0', () => {
107+
expect(numberColumnType.parse(' ', numberCol)).toEqual({ ok: false })
108+
})
109+
110+
it('parse: rejects non-numeric strings', () => {
111+
expect(numberColumnType.parse('abc', numberCol)).toEqual({ ok: false })
112+
})
113+
114+
it('isValidValue: only a finite number passes', () => {
115+
expect(numberColumnType.isValidValue(42, numberCol)).toBeNull()
116+
expect(numberColumnType.isValidValue(Number.NaN, numberCol)).toBe('price must be number')
117+
expect(numberColumnType.isValidValue('42', numberCol)).toBe('price must be number')
118+
})
119+
120+
it('format: stringifies the number', () => {
121+
expect(numberColumnType.format(1234.5, numberCol)).toBe('1234.5')
122+
})
123+
124+
it('isCompatible: finite numbers and numeric strings only', () => {
125+
expect(numberColumnType.isCompatible(42, { type: 'number' })).toBe(true)
126+
expect(numberColumnType.isCompatible('42', { type: 'number' })).toBe(true)
127+
expect(numberColumnType.isCompatible(' ', { type: 'number' })).toBe(false)
128+
expect(numberColumnType.isCompatible('abc', { type: 'number' })).toBe(false)
129+
expect(numberColumnType.isCompatible(true, { type: 'number' })).toBe(false)
130+
})
131+
})
132+
133+
describe('booleanColumnType', () => {
134+
it('sqlCast: compares as text', () => {
135+
expect(booleanColumnType.sqlCast).toBeNull()
136+
})
137+
138+
it('parse: accepts booleans and true/false strings, case- and whitespace-insensitively', () => {
139+
expect(booleanColumnType.parse(true, booleanCol)).toEqual({ ok: true, value: true })
140+
expect(booleanColumnType.parse(' TRUE ', booleanCol)).toEqual({ ok: true, value: true })
141+
expect(booleanColumnType.parse('false', booleanCol)).toEqual({ ok: true, value: false })
142+
expect(booleanColumnType.parse('yes', booleanCol)).toEqual({ ok: false })
143+
})
144+
145+
it('isValidValue: only a boolean passes', () => {
146+
expect(booleanColumnType.isValidValue(true, booleanCol)).toBeNull()
147+
expect(booleanColumnType.isValidValue('true', booleanCol)).toBe('active must be boolean')
148+
})
149+
150+
it('format: stringifies the boolean', () => {
151+
expect(booleanColumnType.format(true, booleanCol)).toBe('true')
152+
})
153+
154+
it('isCompatible: booleans, true/false/1/0 strings, and 0/1 numbers', () => {
155+
expect(booleanColumnType.isCompatible(true, { type: 'boolean' })).toBe(true)
156+
expect(booleanColumnType.isCompatible('1', { type: 'boolean' })).toBe(true)
157+
expect(booleanColumnType.isCompatible(1, { type: 'boolean' })).toBe(true)
158+
expect(booleanColumnType.isCompatible(2, { type: 'boolean' })).toBe(false)
159+
expect(booleanColumnType.isCompatible('yes', { type: 'boolean' })).toBe(false)
160+
})
161+
})
162+
163+
describe('dateColumnType', () => {
164+
it('sqlCast: timestamptz', () => {
165+
expect(dateColumnType.sqlCast).toBe('timestamptz')
166+
})
167+
168+
it('parse: normalizes a calendar date and rejects unparseable strings', () => {
169+
expect(dateColumnType.parse('2024-01-15', dateCol)).toEqual({ ok: true, value: '2024-01-15' })
170+
expect(dateColumnType.parse('not-a-date', dateCol)).toEqual({ ok: false })
171+
})
172+
173+
it('parse: accepts a Date instance and an epoch number', () => {
174+
const result = dateColumnType.parse(new Date('2024-01-15T00:00:00Z'), dateCol)
175+
expect(result.ok).toBe(true)
176+
expect(dateColumnType.parse(1705276800000, dateCol).ok).toBe(true)
177+
})
178+
179+
it('isValidValue: a Date instance or a parseable string passes', () => {
180+
expect(dateColumnType.isValidValue('2024-01-15', dateCol)).toBeNull()
181+
expect(dateColumnType.isValidValue(new Date('2024-01-15'), dateCol)).toBeNull()
182+
expect(dateColumnType.isValidValue('not-a-date', dateCol)).toBe('due must be valid date')
183+
})
184+
185+
it('format: renders a calendar date as MM/DD/YYYY', () => {
186+
expect(dateColumnType.format('2024-01-15', dateCol)).toBe('01/15/2024')
187+
})
188+
189+
it('isCompatible: valid Date instances and parseable strings', () => {
190+
expect(dateColumnType.isCompatible('2024-01-15', { type: 'date' })).toBe(true)
191+
expect(dateColumnType.isCompatible(new Date('2024-01-15'), { type: 'date' })).toBe(true)
192+
expect(dateColumnType.isCompatible('not-a-date', { type: 'date' })).toBe(false)
193+
expect(dateColumnType.isCompatible(42, { type: 'date' })).toBe(false)
194+
})
195+
})
196+
197+
describe('jsonColumnType', () => {
198+
it('sqlCast: compares as text', () => {
199+
expect(jsonColumnType.sqlCast).toBeNull()
200+
})
201+
202+
it('parse: always passes the value through unchanged', () => {
203+
expect(jsonColumnType.parse({ a: 1 }, jsonCol)).toEqual({ ok: true, value: { a: 1 } })
204+
expect(jsonColumnType.parse('plain text', jsonCol)).toEqual({ ok: true, value: 'plain text' })
205+
})
206+
207+
it('isValidValue: anything JSON.stringify can serialize passes', () => {
208+
expect(jsonColumnType.isValidValue({ a: 1 }, jsonCol)).toBeNull()
209+
expect(jsonColumnType.isValidValue([1, 2, 3], jsonCol)).toBeNull()
210+
})
211+
212+
it('isValidValue: a value JSON.stringify cannot serialize (a BigInt) fails', () => {
213+
const unstringifiable = 10n as unknown as JsonValue
214+
expect(jsonColumnType.isValidValue(unstringifiable, jsonCol)).toBe('payload must be valid JSON')
215+
})
216+
217+
it('format: JSON-stringifies the value', () => {
218+
expect(jsonColumnType.format({ a: 1 }, jsonCol)).toBe('{"a":1}')
219+
})
220+
221+
it('isCompatible: always true', () => {
222+
expect(jsonColumnType.isCompatible({ a: 1 }, { type: 'json' })).toBe(true)
223+
expect(jsonColumnType.isCompatible(null, { type: 'json' })).toBe(true)
224+
})
225+
})
226+
227+
describe('selectColumnType', () => {
228+
describe('single-select', () => {
229+
it('parse: resolves an id or a name (case-insensitively), rejects unknown values', () => {
230+
expect(selectColumnType.parse('opt_open', status)).toEqual({ ok: true, value: 'opt_open' })
231+
expect(selectColumnType.parse('closed', status)).toEqual({ ok: true, value: 'opt_closed' })
232+
expect(selectColumnType.parse('nope', status)).toEqual({ ok: false })
233+
})
234+
235+
it('isValidValue: a declared option id passes, anything else fails', () => {
236+
expect(selectColumnType.isValidValue('opt_open', status)).toBeNull()
237+
expect(selectColumnType.isValidValue('opt_unknown', status)).toBe(
238+
'status must be one of the defined options'
239+
)
240+
})
241+
242+
it('format: resolves the stored id to its display name', () => {
243+
expect(selectColumnType.format('opt_open', status)).toBe('Open')
244+
expect(selectColumnType.format(null, status)).toBe('')
245+
})
246+
247+
it('isCompatible: an empty cell is convertible unless the target is required', () => {
248+
expect(selectColumnType.isCompatible('', { type: 'select', options: status.options })).toBe(
249+
true
250+
)
251+
expect(
252+
selectColumnType.isCompatible('', {
253+
type: 'select',
254+
options: status.options,
255+
required: true,
256+
})
257+
).toBe(false)
258+
})
259+
260+
it('isCompatible: a single-select target rejects multiple values', () => {
261+
expect(
262+
selectColumnType.isCompatible(['Open', 'Closed'], {
263+
type: 'select',
264+
options: status.options,
265+
})
266+
).toBe(false)
267+
})
268+
})
269+
270+
describe('multi-select', () => {
271+
it('parse: splits a comma-delimited string, resolves each part, and dedups', () => {
272+
expect(selectColumnType.parse('Alpha, Beta, alpha', tags)).toEqual({
273+
ok: true,
274+
value: ['opt_a', 'opt_b'],
275+
})
276+
})
277+
278+
it('isValidValue: requires an array of declared option ids', () => {
279+
expect(selectColumnType.isValidValue(['opt_a', 'opt_b'], tags)).toBeNull()
280+
expect(selectColumnType.isValidValue('opt_a', tags)).toBe('tags must be a list of options')
281+
expect(selectColumnType.isValidValue(['opt_a', 'nope'], tags)).toBe(
282+
'tags must only contain defined options'
283+
)
284+
})
285+
286+
it('isValidValue: an empty array fails only when required', () => {
287+
expect(selectColumnType.isValidValue([], tags)).toBeNull()
288+
expect(selectColumnType.isValidValue([], { ...tags, required: true })).toBe(
289+
'Missing required field: tags'
290+
)
291+
})
292+
293+
it('format: joins resolved names with a comma', () => {
294+
expect(selectColumnType.format(['opt_b', 'opt_a'], tags)).toBe('Beta, Alpha')
295+
})
296+
297+
it('isCompatible: every part must resolve against the target options', () => {
298+
expect(
299+
selectColumnType.isCompatible('Alpha, Beta', {
300+
type: 'select',
301+
options: tags.options,
302+
multiple: true,
303+
})
304+
).toBe(true)
305+
expect(
306+
selectColumnType.isCompatible('Alpha, Gamma', {
307+
type: 'select',
308+
options: tags.options,
309+
multiple: true,
310+
})
311+
).toBe(false)
312+
})
313+
})
314+
})
315+
316+
describe('convenience wrappers', () => {
317+
it('parseColumnValue delegates to the column type, falling back to ok:false for an unknown type', () => {
318+
expect(parseColumnValue('42', numberCol)).toEqual({ ok: true, value: 42 })
319+
expect(
320+
parseColumnValue('x', { name: 'c', type: 'currency' } as unknown as ColumnDefinition)
321+
).toEqual({
322+
ok: false,
323+
})
324+
})
325+
326+
it('isValidColumnValue delegates to the column type, falling back to an error for an unknown type', () => {
327+
expect(isValidColumnValue(42, numberCol)).toBeNull()
328+
expect(
329+
isValidColumnValue('x', { name: 'c', type: 'currency' } as unknown as ColumnDefinition)
330+
).toBe('Unknown column type "currency"')
331+
})
332+
333+
it('formatColumnValue delegates to the column type, falling back to String(value) for an unknown type', () => {
334+
expect(formatColumnValue('opt_open', status)).toBe('Open')
335+
expect(
336+
formatColumnValue(42, { name: 'c', type: 'currency' } as unknown as ColumnDefinition)
337+
).toBe('42')
338+
})
339+
340+
it('isValueCompatibleWithColumnType treats null/undefined as always compatible', () => {
341+
expect(isValueCompatibleWithColumnType(null, { type: 'number' })).toBe(true)
342+
expect(isValueCompatibleWithColumnType(undefined, { type: 'string' })).toBe(true)
343+
})
344+
345+
it('isValueCompatibleWithColumnType dispatches on the target type, not the source', () => {
346+
expect(isValueCompatibleWithColumnType('Medium', { type: 'string' })).toBe(true)
347+
expect(isValueCompatibleWithColumnType({ a: 1 }, { type: 'string' })).toBe(false)
348+
})
349+
350+
it('sqlCastForColumnType returns the right cast per type and null for unknown/undefined', () => {
351+
expect(sqlCastForColumnType('number')).toBe('numeric')
352+
expect(sqlCastForColumnType('date')).toBe('timestamptz')
353+
expect(sqlCastForColumnType('string')).toBeNull()
354+
expect(sqlCastForColumnType('currency')).toBeNull()
355+
expect(sqlCastForColumnType(undefined)).toBeNull()
356+
})
357+
})

0 commit comments

Comments
 (0)