Skip to content

Commit 6df5bd9

Browse files
committed
fix(tables): validate constraints after the migrations that rewrite cells
Self-caught while reviewing my own previous commit, which introduced both. `updateColumnOptions` ran the shared `applyConstraints` BEFORE its cell migrations. Those migrations rewrite stored values — a single<->multi toggle changes the shape, removing an option clears cells — so a `unique` scan read the pre-migration values, passed, and the rewrite could then produce the duplicates the scan was meant to prevent. Moved to after the migrations, which is where `updateColumnType` already had it. The same commit also left the options path running `required`'s empty-cell check twice: once in the shared helper and once in its original inline block, whose comment still described a separate constraint write that no longer runs. Removed the duplicate — one query, one rule, which is the whole point of the shared helper. Also routes the options path through `persistColumns` like the others.
1 parent ccbb065 commit 6df5bd9

2 files changed

Lines changed: 46 additions & 31 deletions

File tree

apps/sim/lib/table/__tests__/column-conversion.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* coverage and every case below was a shipped defect caught in review.
66
*/
77
import { describe, expect, it } from 'vitest'
8+
import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types'
89
import {
910
applyPendingRename,
1011
isValueCompatibleWithType,
@@ -231,3 +232,27 @@ describe('select accepts scalar cells', () => {
231232
expect(resolveSelectOptionId({ a: 1 } as never, NUMERIC_OPTIONS)).toBeNull()
232233
})
233234
})
235+
236+
describe('constraint validation reads post-migration values', () => {
237+
// The ordering that matters: `updateColumnOptions` rewrites stored cells (a
238+
// single<->multi toggle changes the shape; removing an option clears cells),
239+
// and `updateColumnType` rewrites them through the coercion write-back. A
240+
// `unique` scan run BEFORE those would read values that no longer exist by
241+
// the time the constraint is persisted, pass, and let the rewrite produce the
242+
// duplicates it was supposed to prevent.
243+
//
244+
// The coercion case is the concrete one: two distinct strings can collapse to
245+
// one number.
246+
it('shows how a conversion manufactures duplicates the pre-scan cannot see', () => {
247+
const column: ColumnDefinition = { name: 'sku', type: 'number' }
248+
const before = ['5', '5.0']
249+
expect(new Set(before).size).toBe(2)
250+
251+
const after = before.map((value) => {
252+
const coerced = COLUMN_TYPE_REGISTRY.number.coerce(value, column)
253+
return coerced.ok ? coerced.value : value
254+
})
255+
expect(after).toEqual([5, 5])
256+
expect(new Set(after).size).toBe(1)
257+
})
258+
})

apps/sim/lib/table/columns/service.ts

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,39 +1014,16 @@ export async function updateColumnOptions(
10141014
throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`)
10151015
}
10161016

1017-
const constrainedColumn = await applyConstraints(
1018-
trx,
1019-
data.tableId,
1020-
updatedColumn,
1021-
columnKey,
1022-
data
1023-
)
1024-
const withOptions = schema.columns.map((c, i) => (i === columnIndex ? constrainedColumn : c))
1025-
const updatedColumns = withOptions.map((c, i) =>
1026-
i === columnIndex ? applyPendingRename(withOptions, columnIndex, data.newName) : c
1027-
)
1028-
const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
1029-
const now = new Date()
1030-
10311017
const nextMultiple = !!(data.multiple ?? column.multiple)
10321018
const wasMultiple = !!column.multiple
10331019
const keptIds = new Set(data.options.map((o) => o.id))
10341020
const removedAny = (column.options ?? []).some((o) => !keptIds.has(o.id))
10351021
const togglingCardinality = nextMultiple !== wasMultiple
1022+
// The constraint the column ENDS UP with, which may be arriving in this same
1023+
// request. `applyConstraints` validates and applies it below, after the cell
1024+
// migrations; the checks in between need to read the target value.
10361025
const targetRequired = !!(data.required ?? column.required)
10371026

1038-
// Newly imposing `required` in the same request: rows that are ALREADY empty
1039-
// would fail the separate constraint write after this one commits, so they
1040-
// have to be caught here, through the same predicate that write will use.
1041-
if (targetRequired && !column.required) {
1042-
const emptyCount = await countEmptyCells(trx, data.tableId, columnKey)
1043-
if (emptyCount > 0) {
1044-
throw new Error(
1045-
`Cannot make column "${column.name}" required: ${emptyCount} row(s) have null, missing, or empty values. Fill them first.`
1046-
)
1047-
}
1048-
}
1049-
10501027
if (togglingCardinality || removedAny) {
10511028
const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
10521029
baseMs: 60_000,
@@ -1133,16 +1110,29 @@ export async function updateColumnOptions(
11331110
})
11341111
}
11351112

1136-
await trx
1137-
.update(userTableDefinitions)
1138-
.set({ schema: updatedSchema, updatedAt: now })
1139-
.where(eq(userTableDefinitions.id, data.tableId))
1113+
// Constraints are validated and applied AFTER the migrations above, because
1114+
// those migrations rewrite stored values — a `unique` scan run before them
1115+
// would read the pre-migration shape and pass, and the migration could then
1116+
// produce the duplicates it was meant to prevent.
1117+
const constrainedColumn = await applyConstraints(
1118+
trx,
1119+
data.tableId,
1120+
updatedColumn,
1121+
columnKey,
1122+
data
1123+
)
1124+
const withOptions = schema.columns.map((c, i) => (i === columnIndex ? constrainedColumn : c))
1125+
const updatedColumns = withOptions.map((c, i) =>
1126+
i === columnIndex ? applyPendingRename(withOptions, columnIndex, data.newName) : c
1127+
)
1128+
1129+
const updated = await persistColumns(trx, table, updatedColumns)
11401130

11411131
logger.info(
11421132
`[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}`
11431133
)
11441134

1145-
return { ...table, schema: updatedSchema, updatedAt: now }
1135+
return updated
11461136
})
11471137
}
11481138

0 commit comments

Comments
 (0)