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