Skip to content

Commit 6a87e6e

Browse files
committed
improvement(table): validate range operator value types at SQL builder
1 parent abbdaa1 commit 6a87e6e

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,40 @@ describe('SQL Builder', () => {
266266
})
267267
})
268268

269+
describe('buildFilterClause > range operator value type validation', () => {
270+
it('throws when $gt on a number column receives a string', () => {
271+
const cols: ColumnDefinition[] = [{ name: 'age', type: 'number' }]
272+
expect(() => buildFilterClause({ age: { $gt: 'eighteen' } } as Filter, TABLE, cols)).toThrow(
273+
/column "age" \(number\) requires a number, got string/
274+
)
275+
})
276+
277+
it('throws when $gte on a date column receives a number', () => {
278+
const cols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }]
279+
expect(() =>
280+
buildFilterClause({ birthDate: { $gte: 1704067200000 } } as Filter, TABLE, cols)
281+
).toThrow(/column "birthDate" \(date\) requires a date string, got number/)
282+
})
283+
284+
it('throws when $lt on an unknown column (numeric fallback) receives a string', () => {
285+
expect(() =>
286+
buildFilterClause({ score: { $lt: 'high' } } as Filter, TABLE, NO_COLUMNS)
287+
).toThrow(/column "score" \(number\) requires a number, got string/)
288+
})
289+
290+
it('accepts valid number on number column', () => {
291+
const cols: ColumnDefinition[] = [{ name: 'age', type: 'number' }]
292+
expect(() => buildFilterClause({ age: { $gt: 18 } }, TABLE, cols)).not.toThrow()
293+
})
294+
295+
it('accepts valid ISO string on date column', () => {
296+
const cols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }]
297+
expect(() =>
298+
buildFilterClause({ birthDate: { $gte: '2024-01-01' } }, TABLE, cols)
299+
).not.toThrow()
300+
})
301+
})
302+
269303
describe('buildSortClause', () => {
270304
it('returns undefined for empty sort', () => {
271305
expect(buildSortClause({}, TABLE, NO_COLUMNS)).toBeUndefined()

apps/sim/lib/table/sql.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,31 @@ function validateOperator(operator: string): void {
243243
}
244244
}
245245

246+
/**
247+
* Validates that a range-operator value matches its column's expected JS type
248+
* before it reaches Postgres. Surfaces an actionable, column-named error at the
249+
* SQL builder layer instead of a generic `invalid input syntax for type numeric`
250+
* from the database.
251+
*/
252+
function validateComparisonValue(
253+
field: string,
254+
columnType: ColumnType | undefined,
255+
cast: 'numeric' | 'timestamptz',
256+
value: number | string
257+
): void {
258+
if (cast === 'numeric' && typeof value !== 'number') {
259+
const label = columnType ?? 'number'
260+
throw new TableQueryValidationError(
261+
`Range operator on column "${field}" (${label}) requires a number, got ${typeof value}`
262+
)
263+
}
264+
if (cast === 'timestamptz' && typeof value !== 'string') {
265+
throw new TableQueryValidationError(
266+
`Range operator on column "${field}" (date) requires a date string, got ${typeof value}`
267+
)
268+
}
269+
}
270+
246271
/**
247272
* Builds SQL conditions for a single field based on the provided condition.
248273
*
@@ -423,6 +448,7 @@ function buildComparisonClause(
423448
): SQL {
424449
const escapedField = field.replace(/'/g, "''")
425450
const cast = jsonbCastForType(columnType) ?? 'numeric'
451+
validateComparisonValue(field, columnType, cast, value)
426452
const cell = sql.raw(`(${tableName}.data->>'${escapedField}')::${cast}`)
427453
return cast === 'timestamptz'
428454
? sql`${cell} ${sql.raw(operator)} ${value}::timestamptz`

0 commit comments

Comments
 (0)