Skip to content

Commit fb79784

Browse files
committed
fix: fix date str formula error
1 parent e4b4c00 commit fb79784

3 files changed

Lines changed: 256 additions & 27 deletions

File tree

apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts

Lines changed: 172 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,158 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
2-
import { describe, expect, it } from 'vitest';
2+
import { TableDomain } from '@teable/core';
3+
import { beforeEach, describe, expect, it } from 'vitest';
34

5+
import type { IFieldSelectName } from '../../../features/record/query-builder/field-select.type';
6+
import type { ISelectFormulaConversionContext } from '../../../features/record/query-builder/sql-conversion.visitor';
47
import { SelectQueryPostgres } from './select-query.postgres';
58

69
describe('SelectQueryPostgres unit-aware date helpers', () => {
710
const query = new SelectQueryPostgres();
11+
const mockTable = new TableDomain({
12+
id: 'tblMock',
13+
name: 'Mock Table',
14+
dbTableName: 'mock_table',
15+
lastModifiedTime: '1970-01-01T00:00:00.000Z',
16+
fields: [],
17+
});
18+
19+
const createTimezoneContext = (timeZone: string): ISelectFormulaConversionContext => ({
20+
table: mockTable,
21+
selectionMap: new Map<string, IFieldSelectName>(),
22+
timeZone,
23+
});
24+
25+
describe('timezone-aware wrappers', () => {
26+
let tzQuery: SelectQueryPostgres;
27+
28+
beforeEach(() => {
29+
tzQuery = new SelectQueryPostgres();
30+
tzQuery.setContext(createTimezoneContext('Asia/Shanghai'));
31+
});
32+
33+
it('datestr wraps timezone-adjusted expressions before casting', () => {
34+
expect(tzQuery.datestr('date_col')).toBe(
35+
`((date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::date::text`
36+
);
37+
});
38+
39+
it('timestr wraps timezone-adjusted expressions before casting', () => {
40+
expect(tzQuery.timestr('date_col')).toBe(
41+
`((date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::time::text`
42+
);
43+
});
44+
45+
it('workday casts after timezone normalization', () => {
46+
expect(tzQuery.workday('start_col', '5')).toBe(
47+
`((start_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::date + INTERVAL '5 days'`
48+
);
49+
});
50+
51+
it('dateAdd uses timezone-normalized base expression', () => {
52+
expect(tzQuery.dateAdd('date_col', '2', `'day'`)).toBe(
53+
`(date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai' + ((2)) * INTERVAL '1 day'`
54+
);
55+
});
56+
57+
it('day extracts day after timezone normalization', () => {
58+
expect(tzQuery.day('date_col')).toBe(
59+
`EXTRACT(DAY FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
60+
);
61+
});
62+
63+
it('datetimeFormat formats timezone-normalized timestamp', () => {
64+
expect(tzQuery.datetimeFormat('date_col', `'%Y'`)).toBe(
65+
`TO_CHAR((date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai', '%Y')`
66+
);
67+
});
68+
69+
it('isAfter compares timezone-normalized expressions', () => {
70+
expect(tzQuery.isAfter('date_a', 'date_b')).toBe(
71+
`(date_a)::timestamptz AT TIME ZONE 'Asia/Shanghai' > (date_b)::timestamptz AT TIME ZONE 'Asia/Shanghai'`
72+
);
73+
});
74+
75+
it('isBefore compares timezone-normalized expressions', () => {
76+
expect(tzQuery.isBefore('date_a', 'date_b')).toBe(
77+
`(date_a)::timestamptz AT TIME ZONE 'Asia/Shanghai' < (date_b)::timestamptz AT TIME ZONE 'Asia/Shanghai'`
78+
);
79+
});
80+
81+
it('isSame normalizes unit comparisons after timezone conversion', () => {
82+
expect(tzQuery.isSame('date_a', 'date_b', `'hour'`)).toBe(
83+
`DATE_TRUNC('hour', (date_a)::timestamptz AT TIME ZONE 'Asia/Shanghai') = DATE_TRUNC('hour', (date_b)::timestamptz AT TIME ZONE 'Asia/Shanghai')`
84+
);
85+
});
86+
87+
it('hour extracts hour after timezone normalization', () => {
88+
expect(tzQuery.hour('date_col')).toBe(
89+
`EXTRACT(HOUR FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
90+
);
91+
});
92+
93+
it('minute extracts minute after timezone normalization', () => {
94+
expect(tzQuery.minute('date_col')).toBe(
95+
`EXTRACT(MINUTE FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
96+
);
97+
});
98+
99+
it('second extracts second after timezone normalization', () => {
100+
expect(tzQuery.second('date_col')).toBe(
101+
`EXTRACT(SECOND FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
102+
);
103+
});
104+
105+
it('month extracts month after timezone normalization', () => {
106+
expect(tzQuery.month('date_col')).toBe(
107+
`EXTRACT(MONTH FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
108+
);
109+
});
110+
111+
it('year extracts year after timezone normalization', () => {
112+
expect(tzQuery.year('date_col')).toBe(
113+
`EXTRACT(YEAR FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
114+
);
115+
});
116+
117+
it('weekNum extracts week number after timezone normalization', () => {
118+
expect(tzQuery.weekNum('date_col')).toBe(
119+
`EXTRACT(WEEK FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
120+
);
121+
});
122+
123+
it('weekday extracts day of week after timezone normalization', () => {
124+
expect(tzQuery.weekday('date_col')).toBe(
125+
`EXTRACT(DOW FROM (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai')::int`
126+
);
127+
});
128+
129+
it('toNow computes epoch difference using timezone context', () => {
130+
expect(tzQuery.toNow('date_col')).toBe(
131+
`EXTRACT(EPOCH FROM ((date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai' - (NOW() AT TIME ZONE 'Asia/Shanghai')))`
132+
);
133+
});
134+
135+
it('datetimeDiff subtracts timezone-normalized expressions', () => {
136+
expect(tzQuery.datetimeDiff('start_col', 'end_col', `'day'`)).toBe(
137+
`(EXTRACT(EPOCH FROM ((end_col)::timestamptz AT TIME ZONE 'Asia/Shanghai' - (start_col)::timestamptz AT TIME ZONE 'Asia/Shanghai'))) / 86400`
138+
);
139+
});
140+
141+
it('fromNow uses timezone-aware current timestamp', () => {
142+
expect(tzQuery.fromNow('date_col')).toBe(
143+
`EXTRACT(EPOCH FROM ((NOW() AT TIME ZONE 'Asia/Shanghai') - (date_col)::timestamptz AT TIME ZONE 'Asia/Shanghai'))`
144+
);
145+
});
146+
147+
it('escapes single quotes in timezone identifiers', () => {
148+
const customTzQuery = new SelectQueryPostgres();
149+
customTzQuery.setContext(createTimezoneContext("America/St_John's"));
150+
151+
expect(customTzQuery.datestr('date_col')).toBe(
152+
`((date_col)::timestamptz AT TIME ZONE 'America/St_John''s')::date::text`
153+
);
154+
});
155+
});
8156

9157
const dateAddCases: Array<{ literal: string; unit: string; factor: number }> = [
10158
{ literal: 'millisecond', unit: 'millisecond', factor: 1 },
@@ -37,85 +185,87 @@ describe('SelectQueryPostgres unit-aware date helpers', () => {
37185
it.each(dateAddCases)('dateAdd normalizes unit "%s" to "%s"', ({ literal, unit, factor }) => {
38186
const sql = query.dateAdd('date_col', 'count_expr', `'${literal}'`);
39187
const scaled = factor === 1 ? '(count_expr)' : `(count_expr) * ${factor}`;
40-
expect(sql).toBe(`date_col::timestamp + (${scaled}) * INTERVAL '1 ${unit}'`);
188+
expect(sql).toBe(`(date_col)::timestamp + (${scaled}) * INTERVAL '1 ${unit}'`);
41189
});
42190

43191
const datetimeDiffCases: Array<{ literal: string; expected: string }> = [
44192
{
45193
literal: 'millisecond',
46-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) * 1000',
194+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) * 1000',
47195
},
48196
{
49197
literal: 'milliseconds',
50-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) * 1000',
198+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) * 1000',
51199
},
52200
{
53201
literal: 'ms',
54-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) * 1000',
202+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) * 1000',
55203
},
56204
{
57205
literal: 'second',
58-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp)))',
206+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp)))',
59207
},
60208
{
61209
literal: 'seconds',
62-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp)))',
210+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp)))',
63211
},
64212
{
65213
literal: 'sec',
66-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp)))',
214+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp)))',
67215
},
68216
{
69217
literal: 'secs',
70-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp)))',
218+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp)))',
71219
},
72220
{
73221
literal: 'minute',
74-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 60',
222+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 60',
75223
},
76224
{
77225
literal: 'minutes',
78-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 60',
226+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 60',
79227
},
80228
{
81229
literal: 'min',
82-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 60',
230+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 60',
83231
},
84232
{
85233
literal: 'mins',
86-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 60',
234+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 60',
87235
},
88236
{
89237
literal: 'hour',
90-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 3600',
238+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 3600',
91239
},
92240
{
93241
literal: 'hours',
94-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 3600',
242+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 3600',
95243
},
96244
{
97245
literal: 'hr',
98-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 3600',
246+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 3600',
99247
},
100248
{
101249
literal: 'hrs',
102-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 3600',
250+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 3600',
103251
},
104252
{
105253
literal: 'week',
106-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / (86400 * 7)',
254+
expected:
255+
'(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / (86400 * 7)',
107256
},
108257
{
109258
literal: 'weeks',
110-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / (86400 * 7)',
259+
expected:
260+
'(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / (86400 * 7)',
111261
},
112262
{
113263
literal: 'day',
114-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 86400',
264+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 86400',
115265
},
116266
{
117267
literal: 'days',
118-
expected: '(EXTRACT(EPOCH FROM (date_end::timestamp - date_start::timestamp))) / 86400',
268+
expected: '(EXTRACT(EPOCH FROM ((date_end)::timestamp - (date_start)::timestamp))) / 86400',
119269
},
120270
];
121271

@@ -155,7 +305,7 @@ describe('SelectQueryPostgres unit-aware date helpers', () => {
155305
it.each(isSameCases)('isSame normalizes unit "%s"', ({ literal, expectedUnit }) => {
156306
const sql = query.isSame('date_a', 'date_b', `'${literal}'`);
157307
expect(sql).toBe(
158-
`DATE_TRUNC('${expectedUnit}', date_a::timestamp) = DATE_TRUNC('${expectedUnit}', date_b::timestamp)`
308+
`DATE_TRUNC('${expectedUnit}', (date_a)::timestamp) = DATE_TRUNC('${expectedUnit}', (date_b)::timestamp)`
159309
);
160310
});
161311

apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,13 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
226226
const tz = this.context?.timeZone as string | undefined;
227227
if (!tz) {
228228
// Default behavior: interpret as timestamp without timezone
229-
return `${date}::timestamp`;
229+
return `(${date})::timestamp`;
230230
}
231231
// Sanitize single quotes to prevent SQL issues
232232
const safeTz = tz.replace(/'/g, "''");
233233
// Interpret input as timestamptz if it has offset and convert to target timezone
234234
// AT TIME ZONE returns timestamp without time zone in that zone
235-
return `${date}::timestamptz AT TIME ZONE '${safeTz}'`;
235+
return `(${date})::timestamptz AT TIME ZONE '${safeTz}'`;
236236
}
237237
// Numeric Functions
238238
sum(params: string[]): string {
@@ -437,7 +437,7 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
437437
}
438438

439439
datestr(date: string): string {
440-
return `${this.tzWrap(date)}::date::text`;
440+
return `(${this.tzWrap(date)})::date::text`;
441441
}
442442

443443
datetimeDiff(startDate: string, endDate: string, unit: string): string {
@@ -531,7 +531,7 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
531531
}
532532

533533
timestr(date: string): string {
534-
return `${this.tzWrap(date)}::time::text`;
534+
return `(${this.tzWrap(date)})::time::text`;
535535
}
536536

537537
toNow(date: string): string {
@@ -552,7 +552,7 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
552552

553553
workday(startDate: string, days: string): string {
554554
// Simplified implementation in the target timezone
555-
return `${this.tzWrap(startDate)}::date + INTERVAL '${days} days'`;
555+
return `(${this.tzWrap(startDate)})::date + INTERVAL '${days} days'`;
556556
}
557557

558558
workdayDiff(startDate: string, endDate: string): string {

0 commit comments

Comments
 (0)