Skip to content

Commit 8503120

Browse files
committed
improvement(table): cast jsonb date filters/sorts to timestamptz
::timestamp strips timezone offsets from ISO strings, making comparisons depend on the server TimeZone setting. ::timestamptz preserves the offset so chronological comparisons are correct regardless of server config.
1 parent a0da8e2 commit 8503120

2 files changed

Lines changed: 21 additions & 18 deletions

File tree

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
* SQL Builder Unit Tests
55
*
66
* Tests the table SQL query builder. Assertions inspect the generated SQL
7-
* string so cast selection (numeric vs timestamp) is verified end-to-end.
7+
* string so cast selection (numeric vs timestamptz) is verified end-to-end.
88
*
99
* Rendering: `drizzle-orm` is globally mocked in `vitest.setup.ts`. The mock
1010
* represents tagged-template fragments as `{ strings, values }`, raw fragments
1111
* as `{ rawSql }`, and joined fragments as `{ fragments, separator }`. The
1212
* local `renderSql` helper walks that shape recursively so we can assert real
13-
* substrings like `::timestamp` against the generated SQL.
13+
* substrings like `::timestamptz` against the generated SQL.
1414
*/
1515
import { describe, expect, it } from 'vitest'
1616
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
@@ -205,25 +205,25 @@ describe('SQL Builder', () => {
205205
['$gte', '>='],
206206
['$lt', '<'],
207207
['$lte', '<='],
208-
] as const)('emits ::timestamp on both sides for %s on a date column', (operator, sqlOp) => {
208+
] as const)('emits ::timestamptz on both sides for %s on a date column', (operator, sqlOp) => {
209209
const filter = { birthDate: { [operator]: '2024-01-01' } } as Filter
210210
const out = render(buildFilterClause(filter, TABLE, dateCols))
211-
expect(out).toContain(`(${TABLE}.data->>'birthDate')::timestamp ${sqlOp} `)
212-
expect(out).toContain('::timestamp')
211+
expect(out).toContain(`(${TABLE}.data->>'birthDate')::timestamptz ${sqlOp} `)
212+
expect(out).toContain('::timestamptz')
213213
expect(out).not.toContain('::numeric')
214214
// RHS cast — without it Postgres would compare as text (lexicographic).
215-
expect(out.match(/::timestamp/g)?.length).toBe(2)
215+
expect(out.match(/::timestamptz/g)?.length).toBe(2)
216216
})
217217

218-
it('combined range ($gte + $lte) emits two ::timestamp pairs', () => {
218+
it('combined range ($gte + $lte) emits two ::timestamptz pairs', () => {
219219
const out = render(
220220
buildFilterClause(
221221
{ birthDate: { $gte: '2024-01-01', $lte: '2024-12-31' } },
222222
TABLE,
223223
dateCols
224224
)
225225
)
226-
expect(out.match(/::timestamp/g)?.length).toBe(4)
226+
expect(out.match(/::timestamptz/g)?.length).toBe(4)
227227
expect(out).not.toContain('::numeric')
228228
expect(out).toContain(' AND ')
229229
})
@@ -236,7 +236,7 @@ describe('SQL Builder', () => {
236236
dateCols
237237
)
238238
)
239-
expect(out).toContain('::timestamp')
239+
expect(out).toContain('::timestamptz')
240240
expect(out).not.toContain('::numeric')
241241
})
242242

@@ -248,7 +248,7 @@ describe('SQL Builder', () => {
248248
dateCols
249249
)
250250
)
251-
expect(out).toContain('::timestamp')
251+
expect(out).toContain('::timestamptz')
252252
expect(out).not.toContain('::numeric')
253253
expect(out).toContain(' OR ')
254254
})
@@ -261,7 +261,7 @@ describe('SQL Builder', () => {
261261
const out = render(
262262
buildFilterClause({ birthDate: { $gte: '2024-01-01' }, age: { $gt: 18 } }, TABLE, cols)
263263
)
264-
expect(out).toContain('::timestamp')
264+
expect(out).toContain('::timestamptz')
265265
expect(out).toContain('::numeric')
266266
})
267267
})
@@ -284,10 +284,10 @@ describe('SQL Builder', () => {
284284
expect(out).toBe(`(${TABLE}.data->>'salary')::numeric DESC NULLS LAST`)
285285
})
286286

287-
it('sorts date columns with ::timestamp NULLS LAST', () => {
287+
it('sorts date columns with ::timestamptz NULLS LAST', () => {
288288
const cols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }]
289289
const out = render(buildSortClause({ birthDate: 'asc' }, TABLE, cols))
290-
expect(out).toBe(`(${TABLE}.data->>'birthDate')::timestamp ASC NULLS LAST`)
290+
expect(out).toBe(`(${TABLE}.data->>'birthDate')::timestamptz ASC NULLS LAST`)
291291
})
292292

293293
it('sorts createdAt / updatedAt as direct column refs', () => {

apps/sim/lib/table/sql.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ type ColumnTypeMap = ReadonlyMap<string, ColumnType>
3030
* truth for both filter range operators and sort ordering — keeps the two
3131
* paths from drifting apart.
3232
*/
33-
function jsonbCastForType(type: ColumnType | undefined): 'numeric' | 'timestamp' | null {
33+
function jsonbCastForType(type: ColumnType | undefined): 'numeric' | 'timestamptz' | null {
3434
switch (type) {
3535
case 'number':
3636
return 'numeric'
3737
case 'date':
38-
return 'timestamp'
38+
return 'timestamptz'
3939
default:
4040
return null
4141
}
@@ -386,7 +386,10 @@ function buildContainmentClause(tableName: string, field: string, value: JsonVal
386386
* Builds a typed range comparison against a JSONB cell.
387387
*
388388
* `number` columns cast both sides to `numeric`; `date` columns cast both sides
389-
* to `timestamp` so date strings compare chronologically. Unknown/other types
389+
* to `timestamptz` so date strings compare chronologically and timezone offsets
390+
* in ISO strings (e.g. `2024-01-01T00:00:00Z`) are preserved rather than
391+
* silently stripped (which would make results depend on the server's TimeZone
392+
* setting). Unknown/other types
390393
* fall back to `numeric` (legacy default — preserves behavior for ad-hoc fields
391394
* with no schema entry). The right-hand value is cast explicitly because
392395
* drizzle parameterizes it as `text`; without the cast, Postgres would compare
@@ -405,8 +408,8 @@ function buildComparisonClause(
405408
const escapedField = field.replace(/'/g, "''")
406409
const cast = jsonbCastForType(columnType) ?? 'numeric'
407410
const cell = sql.raw(`(${tableName}.data->>'${escapedField}')::${cast}`)
408-
return cast === 'timestamp'
409-
? sql`${cell} ${sql.raw(operator)} ${value}::timestamp`
411+
return cast === 'timestamptz'
412+
? sql`${cell} ${sql.raw(operator)} ${value}::timestamptz`
410413
: sql`${cell} ${sql.raw(operator)} ${value}`
411414
}
412415

0 commit comments

Comments
 (0)