Skip to content

Commit 4f72933

Browse files
fix(tables): persist coerced values in upsert match + bulk update, normalize Date to ISO
1 parent fe6ea03 commit 4f72933

5 files changed

Lines changed: 76 additions & 16 deletions

File tree

apps/sim/lib/table/__tests__/service-filter-threading.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ vi.mock('@/lib/table/validation', () => ({
3535
validateRowSize: vi.fn(() => ({ valid: true, errors: [] })),
3636
validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })),
3737
coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })),
38+
coerceRowValues: vi.fn(),
3839
validateTableName: vi.fn(() => ({ valid: true, errors: [] })),
3940
validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })),
4041
getUniqueColumns: vi.fn(() => []),

apps/sim/lib/table/__tests__/update-row.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vi.mock('@/lib/table/validation', () => ({
2121
validateRowSize: vi.fn(() => ({ valid: true, errors: [] })),
2222
validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })),
2323
coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })),
24+
coerceRowValues: vi.fn(),
2425
validateTableName: vi.fn(() => ({ valid: true, errors: [] })),
2526
validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })),
2627
getUniqueColumns: vi.fn(() => []),

apps/sim/lib/table/__tests__/validation.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TABLE_LIMITS } from '../constants'
66
import {
77
type ColumnDefinition,
88
coerceRowToSchema,
9+
coerceRowValues,
910
getUniqueColumns,
1011
type TableSchema,
1112
validateColumnDefinition,
@@ -334,6 +335,14 @@ describe('Validation', () => {
334335
expect(data.created).toBe(new Date(epoch).toISOString())
335336
})
336337

338+
it('coerces a Date instance to an ISO date string', () => {
339+
const date = new Date('2024-01-15T00:00:00Z')
340+
const data = { name: 'Acme', founded: 2000, created: date }
341+
const result = coerceRowToSchema(data, schema)
342+
expect(result.valid).toBe(true)
343+
expect(data.created).toBe(date.toISOString())
344+
})
345+
337346
it('leaves already-correct values untouched and passes through json', () => {
338347
const data = { name: 'Acme', founded: 2000, metadata: { k: 'v' } }
339348
const result = coerceRowToSchema(data, schema)
@@ -349,6 +358,34 @@ describe('Validation', () => {
349358
})
350359
})
351360

361+
describe('coerceRowValues', () => {
362+
const schema: TableSchema = {
363+
columns: [
364+
{ name: 'name', type: 'string', required: true },
365+
{ name: 'founded', type: 'number', required: true },
366+
{ name: 'age', type: 'number' },
367+
],
368+
}
369+
370+
it('coerces a partial patch in place without flagging absent required fields', () => {
371+
const patch = { age: '42' }
372+
coerceRowValues(patch, schema)
373+
expect(patch.age).toBe(42)
374+
})
375+
376+
it('nulls an un-coercible optional value in a patch', () => {
377+
const patch: { age: unknown } = { age: 'nope' }
378+
coerceRowValues(patch as never, schema)
379+
expect(patch.age).toBeNull()
380+
})
381+
382+
it('leaves an un-coercible required value in place for downstream validation', () => {
383+
const patch: { founded: unknown } = { founded: 'nope' }
384+
coerceRowValues(patch as never, schema)
385+
expect(patch.founded).toBe('nope')
386+
})
387+
})
388+
352389
describe('getUniqueColumns', () => {
353390
it('should return only columns with unique=true', () => {
354391
const schema: TableSchema = {

apps/sim/lib/table/service.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
checkBatchUniqueConstraintsDb,
6363
checkUniqueConstraintsDb,
6464
coerceRowToSchema,
65+
coerceRowValues,
6566
getUniqueColumns,
6667
validateRowSize,
6768
validateTableName,
@@ -1331,11 +1332,6 @@ export async function upsertRow(
13311332
)
13321333
}
13331334

1334-
const targetValue = data.data[targetColumnName]
1335-
if (targetValue === undefined || targetValue === null) {
1336-
throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`)
1337-
}
1338-
13391335
// Validate row data
13401336
const sizeValidation = validateRowSize(data.data)
13411337
if (!sizeValidation.valid) {
@@ -1347,6 +1343,13 @@ export async function upsertRow(
13471343
throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`)
13481344
}
13491345

1346+
// Read the conflict-target value *after* coercion so `matchFilter` branches on
1347+
// the persisted type (e.g. a coerced `"123"` → `123` matches existing rows).
1348+
const targetValue = data.data[targetColumnName]
1349+
if (targetValue === undefined || targetValue === null) {
1350+
throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`)
1351+
}
1352+
13501353
// `data->` and `data->>` accept the JSON key as a parameterized text value;
13511354
// no need for `sql.raw` interpolation.
13521355
const matchFilter =
@@ -2167,6 +2170,12 @@ export async function updateRowsByFilter(
21672170
return { affectedCount: 0, affectedRowIds: [] }
21682171
}
21692172

2173+
// Coerce the patch itself in place — the write below persists `data.data`
2174+
// (as `patchJson`), so coercing only the per-row merged copies would be
2175+
// discarded. The merged validation in the loop still enforces required
2176+
// fields against the full row.
2177+
coerceRowValues(data.data, table.schema)
2178+
21702179
for (const row of matchingRows) {
21712180
const existingData = row.data as RowData
21722181
const mergedData = { ...existingData, ...data.data }

apps/sim/lib/table/validation.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ function coerceValueToColumnType(
290290
}
291291
return { ok: false }
292292
case 'date':
293-
if (value instanceof Date) return { ok: true, value }
293+
if (value instanceof Date) return { ok: true, value: value.toISOString() }
294294
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return { ok: true, value }
295295
if (typeof value === 'number' && Number.isFinite(value)) {
296296
return { ok: true, value: new Date(value).toISOString() }
@@ -302,18 +302,16 @@ function coerceValueToColumnType(
302302
}
303303

304304
/**
305-
* Coerces each value in `data` toward its column's declared type **in place**,
306-
* then validates the result. Values that already match are untouched;
307-
* unambiguous conversions (e.g. `"1999"` → `1999`) are applied; values that
308-
* cannot be coerced are set to `null` when the column is optional, or left in
309-
* place to fail validation when the column is required.
305+
* Coerces each present value in `data` toward its column's declared type **in
306+
* place**. Values that already match are untouched; unambiguous conversions
307+
* (e.g. `"1999"` → `1999`) are applied; values that cannot be coerced are set to
308+
* `null` when the column is optional, or left in place when required (so a
309+
* subsequent {@link validateRowAgainstSchema} reports them).
310310
*
311-
* This is the write-path entry point — callers that persist rows use it instead
312-
* of {@link validateRowAgainstSchema} so a single off-type field (a tool
313-
* returning `"unknown"` for a numeric column, say) nulls that one cell rather
314-
* than failing the entire row write.
311+
* Operates per-present-column, so it is safe on a partial patch (columns absent
312+
* from `data` are skipped — it never invents a missing-required-field error).
315313
*/
316-
export function coerceRowToSchema(data: RowData, schema: TableSchema): ValidationResult {
314+
export function coerceRowValues(data: RowData, schema: TableSchema): void {
317315
for (const column of schema.columns) {
318316
const value = data[column.name]
319317
if (value === null || value === undefined) continue
@@ -325,7 +323,21 @@ export function coerceRowToSchema(data: RowData, schema: TableSchema): Validatio
325323
data[column.name] = null
326324
}
327325
}
326+
}
328327

328+
/**
329+
* Coerces a full row toward its schema **in place** (see {@link coerceRowValues})
330+
* then validates the result.
331+
*
332+
* This is the write-path entry point — callers that persist a complete row use
333+
* it instead of {@link validateRowAgainstSchema} so a single off-type field (a
334+
* tool returning `"unknown"` for a numeric column, say) nulls that one cell
335+
* rather than failing the entire row write. Callers persisting only a partial
336+
* patch should use {@link coerceRowValues} on the patch and validate the merged
337+
* row separately.
338+
*/
339+
export function coerceRowToSchema(data: RowData, schema: TableSchema): ValidationResult {
340+
coerceRowValues(data, schema)
329341
return validateRowAgainstSchema(data, schema)
330342
}
331343

0 commit comments

Comments
 (0)