Skip to content

Commit ee60e85

Browse files
authored
Merge pull request #2051 from teableio/fix/bugs
2 parents b310b8f + 6cc9a47 commit ee60e85

26 files changed

Lines changed: 2118 additions & 253 deletions

‎apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/formula-query.spec.ts.snap‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Fun
6060

6161
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeFormat function with parameters 1`] = `"TO_CHAR(column_a::timestamp, 'YYYY-MM-DD')"`;
6262

63-
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `"TO_TIMESTAMP(column_a, 'YYYY-MM-DD')"`;
63+
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `"(CASE WHEN (column_a) IS NULL THEN NULL WHEN (column_a)::text = '' THEN NULL WHEN (column_a)::text ~ '^\\d{4}\\-\\d{2}\\-\\d{2}$' THEN TO_TIMESTAMP((column_a)::text, 'YYYY-MM-DD') ELSE NULL END)"`;
6464

6565
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement day function 1`] = `"EXTRACT(DAY FROM column_a::timestamp)"`;
6666

‎apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/generated-column-query.spec.ts.snap‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Fun
6060

6161
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeFormat function with parameters 1`] = `"TO_CHAR(column_a::timestamp, 'YYYY-MM-DD')"`;
6262

63-
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `"TO_TIMESTAMP(column_a, 'YYYY-MM-DD')"`;
63+
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `"(CASE WHEN (column_a) IS NULL THEN NULL WHEN (column_a)::text = '' THEN NULL WHEN (column_a)::text ~ '^\\d{4}\\-\\d{2}\\-\\d{2}$' THEN TO_TIMESTAMP((column_a)::text, 'YYYY-MM-DD') ELSE NULL END)"`;
6464

6565
exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement day function 1`] = `"EXTRACT(DAY FROM column_a::timestamp)"`;
6666

‎apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.spec.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ describe('GeneratedColumnQueryPostgres unit-aware helpers', () => {
1616
query.setContext(stubContext);
1717
});
1818

19+
it('left casts expressions to text for generated columns', () => {
20+
expect(query.left('raw_expr', '5')).toBe(`LEFT((raw_expr)::text, 5::integer)`);
21+
});
22+
23+
it('right casts expressions to text for generated columns', () => {
24+
expect(query.right('raw_expr', '4')).toBe(`RIGHT((raw_expr)::text, 4::integer)`);
25+
});
26+
27+
it('mid casts expressions to text for generated columns', () => {
28+
expect(query.mid('raw_expr', '2', '5')).toBe(
29+
`SUBSTRING((raw_expr)::text FROM 2::integer FOR 5::integer)`
30+
);
31+
});
32+
1933
const dateAddCases: Array<{ literal: string; unit: string; factor: number }> = [
2034
{ literal: 'millisecond', unit: 'millisecond', factor: 1 },
2135
{ literal: 'milliseconds', unit: 'millisecond', factor: 1 },

‎apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.ts‎

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
191191
}
192192

193193
value(text: string): string {
194-
return `${text}::numeric`;
194+
return this.toNumericSafe(text);
195195
}
196196

197197
// Text Functions
@@ -249,15 +249,15 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
249249
}
250250

251251
mid(text: string, startNum: string, numChars: string): string {
252-
return `SUBSTRING(${text} FROM ${startNum}::integer FOR ${numChars}::integer)`;
252+
return `SUBSTRING((${text})::text FROM ${startNum}::integer FOR ${numChars}::integer)`;
253253
}
254254

255255
left(text: string, numChars: string): string {
256-
return `LEFT(${text}, ${numChars}::integer)`;
256+
return `LEFT((${text})::text, ${numChars}::integer)`;
257257
}
258258

259259
right(text: string, numChars: string): string {
260-
return `RIGHT(${text}, ${numChars}::integer)`;
260+
return `RIGHT((${text})::text, ${numChars}::integer)`;
261261
}
262262

263263
replace(oldText: string, startNum: string, numChars: string, newText: string): string {
@@ -504,7 +504,15 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
504504
if (!normalized || normalized === 'undefined' || normalized.toLowerCase() === 'null') {
505505
return dateString;
506506
}
507-
return `TO_TIMESTAMP(${dateString}, ${format})`;
507+
const valueExpr = `(${dateString})`;
508+
const toTimestampExpr = `TO_TIMESTAMP(${valueExpr}::text, ${format})`;
509+
const guardPattern = this.buildDatetimeParseGuardRegex(normalized);
510+
if (!guardPattern) {
511+
return toTimestampExpr;
512+
}
513+
const textExpr = `${valueExpr}::text`;
514+
const escapedPattern = guardPattern.replace(/'/g, "''");
515+
return `(CASE WHEN ${valueExpr} IS NULL THEN NULL WHEN ${textExpr} = '' THEN NULL WHEN ${textExpr} ~ '${escapedPattern}' THEN ${toTimestampExpr} ELSE NULL END)`;
508516
}
509517

510518
day(date: string): string {
@@ -628,7 +636,10 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
628636
return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;
629637
}
630638
// For multiple values, we need a more complex implementation
631-
return `(${this.joinParams(params, ' + ')}) % 2 = 1`;
639+
return `(${this.joinParams(
640+
params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`),
641+
' + '
642+
)}) % 2 = 1`;
632643
}
633644

634645
blank(): string {
@@ -747,4 +758,57 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
747758
protected escapeIdentifier(identifier: string): string {
748759
return `"${identifier.replace(/"/g, '""')}"`;
749760
}
761+
762+
private buildDatetimeParseGuardRegex(formatLiteral: string): string | null {
763+
if (!formatLiteral.startsWith("'") || !formatLiteral.endsWith("'")) {
764+
return null;
765+
}
766+
const literal = formatLiteral.slice(1, -1);
767+
const tokenPatterns: Array<[string, string]> = [
768+
['HH24', '\\d{2}'],
769+
['HH12', '\\d{2}'],
770+
['HH', '\\d{2}'],
771+
['MI', '\\d{2}'],
772+
['SS', '\\d{2}'],
773+
['MS', '\\d{1,3}'],
774+
['YYYY', '\\d{4}'],
775+
['YYY', '\\d{3}'],
776+
['YY', '\\d{2}'],
777+
['Y', '\\d'],
778+
['MM', '\\d{2}'],
779+
['DD', '\\d{2}'],
780+
];
781+
const optionalTokens = new Set(['FM', 'TM', 'TH']);
782+
let pattern = '^';
783+
for (let i = 0; i < literal.length; ) {
784+
let matched = false;
785+
const remaining = literal.slice(i);
786+
const upperRemaining = remaining.toUpperCase();
787+
for (const [token, tokenPattern] of tokenPatterns) {
788+
if (upperRemaining.startsWith(token)) {
789+
pattern += tokenPattern;
790+
i += token.length;
791+
matched = true;
792+
break;
793+
}
794+
}
795+
if (matched) {
796+
continue;
797+
}
798+
const optionalToken = upperRemaining.slice(0, 2);
799+
if (optionalTokens.has(optionalToken)) {
800+
i += optionalToken.length;
801+
continue;
802+
}
803+
const currentChar = literal[i];
804+
if (/\s/.test(currentChar)) {
805+
pattern += '\\s';
806+
} else {
807+
pattern += currentChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
808+
}
809+
i += 1;
810+
}
811+
pattern += '$';
812+
return pattern;
813+
}
750814
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ describe('SelectQueryPostgres unit-aware date helpers', () => {
2222
timeZone,
2323
});
2424

25+
it('left casts expressions to text before truncation', () => {
26+
expect(query.left('raw_expr', '5')).toBe(`LEFT((raw_expr)::text, 5::integer)`);
27+
});
28+
29+
it('right casts expressions to text before truncation', () => {
30+
expect(query.right('raw_expr', '4')).toBe(`RIGHT((raw_expr)::text, 4::integer)`);
31+
});
32+
33+
it('mid casts expressions to text before slicing', () => {
34+
expect(query.mid('raw_expr', '2', '5')).toBe(
35+
`SUBSTRING((raw_expr)::text FROM 2::integer FOR 5::integer)`
36+
);
37+
});
38+
2539
describe('timezone-aware wrappers', () => {
2640
let tzQuery: SelectQueryPostgres;
2741

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

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,17 +222,22 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
222222
return `(${normalizedLeft} ${operator} ${normalizedRight})`;
223223
}
224224

225+
private sanitizeTimestampInput(date: string): string {
226+
return `NULLIF(BTRIM((${date})::text), '')`;
227+
}
228+
225229
private tzWrap(date: string): string {
226230
const tz = this.context?.timeZone as string | undefined;
231+
const sanitized = this.sanitizeTimestampInput(date);
227232
if (!tz) {
228233
// Default behavior: interpret as timestamp without timezone
229-
return `(${date})::timestamp`;
234+
return `(${sanitized})::timestamp`;
230235
}
231236
// Sanitize single quotes to prevent SQL issues
232237
const safeTz = tz.replace(/'/g, "''");
233238
// Interpret input as timestamptz if it has offset and convert to target timezone
234239
// AT TIME ZONE returns timestamp without time zone in that zone
235-
return `(${date})::timestamptz AT TIME ZONE '${safeTz}'`;
240+
return `(${sanitized})::timestamptz AT TIME ZONE '${safeTz}'`;
236241
}
237242
// Numeric Functions
238243
sum(params: string[]): string {
@@ -332,7 +337,7 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
332337
}
333338

334339
value(text: string): string {
335-
return `${text}::numeric`;
340+
return this.toNumericSafe(text);
336341
}
337342

338343
// Text Functions
@@ -361,15 +366,15 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
361366
}
362367

363368
mid(text: string, startNum: string, numChars: string): string {
364-
return `SUBSTRING(${text} FROM ${startNum}::integer FOR ${numChars}::integer)`;
369+
return `SUBSTRING((${text})::text FROM ${startNum}::integer FOR ${numChars}::integer)`;
365370
}
366371

367372
left(text: string, numChars: string): string {
368-
return `LEFT(${text}, ${numChars}::integer)`;
373+
return `LEFT((${text})::text, ${numChars}::integer)`;
369374
}
370375

371376
right(text: string, numChars: string): string {
372-
return `RIGHT(${text}, ${numChars}::integer)`;
377+
return `RIGHT((${text})::text, ${numChars}::integer)`;
373378
}
374379

375380
replace(oldText: string, startNum: string, numChars: string, newText: string): string {
@@ -472,7 +477,15 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
472477
if (!normalized || normalized === 'undefined' || normalized.toLowerCase() === 'null') {
473478
return dateString;
474479
}
475-
return `TO_TIMESTAMP(${dateString}, ${format})`;
480+
const valueExpr = `(${dateString})`;
481+
const toTimestampExpr = `TO_TIMESTAMP(${valueExpr}::text, ${format})`;
482+
const guardPattern = this.buildDatetimeParseGuardRegex(normalized);
483+
if (!guardPattern) {
484+
return toTimestampExpr;
485+
}
486+
const textExpr = `${valueExpr}::text`;
487+
const escapedPattern = guardPattern.replace(/'/g, "''");
488+
return `(CASE WHEN ${valueExpr} IS NULL THEN NULL WHEN ${textExpr} = '' THEN NULL WHEN ${textExpr} ~ '${escapedPattern}' THEN ${toTimestampExpr} ELSE NULL END)`;
476489
}
477490

478491
day(date: string): string {
@@ -570,8 +583,8 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
570583
}
571584

572585
// Logical Functions
573-
if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
574-
const wrapped = `(${condition})`;
586+
private truthinessScore(value: string): string {
587+
const wrapped = `(${value})`;
575588
const conditionType = `pg_typeof${wrapped}::text`;
576589
const numericTypes = "('smallint','integer','bigint','numeric','double precision','real')";
577590
const wrappedText = `(${wrapped})::text`;
@@ -582,12 +595,16 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
582595
WHEN LOWER(${wrappedText}) = 'null' THEN 0
583596
ELSE 1
584597
END`;
585-
const truthinessScore = `CASE
598+
return `CASE
586599
WHEN ${wrapped} IS NULL THEN 0
587600
WHEN ${conditionType} = 'boolean' THEN ${booleanTruthyScore}
588601
WHEN ${conditionType} IN ${numericTypes} THEN ${numericTruthyScore}
589602
ELSE ${fallbackTruthyScore}
590603
END`;
604+
}
605+
606+
if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
607+
const truthinessScore = this.truthinessScore(condition);
591608
return `CASE WHEN (${truthinessScore}) = 1 THEN ${valueIfTrue} ELSE ${valueIfFalse} END`;
592609
}
593610

@@ -860,4 +877,57 @@ export class SelectQueryPostgres extends SelectQueryAbstract {
860877
parentheses(expression: string): string {
861878
return `(${expression})`;
862879
}
880+
881+
private buildDatetimeParseGuardRegex(formatLiteral: string): string | null {
882+
if (!formatLiteral.startsWith("'") || !formatLiteral.endsWith("'")) {
883+
return null;
884+
}
885+
const literal = formatLiteral.slice(1, -1);
886+
const tokenPatterns: Array<[string, string]> = [
887+
['HH24', '\\d{2}'],
888+
['HH12', '\\d{2}'],
889+
['HH', '\\d{2}'],
890+
['MI', '\\d{2}'],
891+
['SS', '\\d{2}'],
892+
['MS', '\\d{1,3}'],
893+
['YYYY', '\\d{4}'],
894+
['YYY', '\\d{3}'],
895+
['YY', '\\d{2}'],
896+
['Y', '\\d'],
897+
['MM', '\\d{2}'],
898+
['DD', '\\d{2}'],
899+
];
900+
const optionalTokens = new Set(['FM', 'TM', 'TH']);
901+
let pattern = '^';
902+
for (let i = 0; i < literal.length; ) {
903+
let matched = false;
904+
const remaining = literal.slice(i);
905+
const upperRemaining = remaining.toUpperCase();
906+
for (const [token, tokenPattern] of tokenPatterns) {
907+
if (upperRemaining.startsWith(token)) {
908+
pattern += tokenPattern;
909+
i += token.length;
910+
matched = true;
911+
break;
912+
}
913+
}
914+
if (matched) {
915+
continue;
916+
}
917+
const optionalToken = upperRemaining.slice(0, 2);
918+
if (optionalTokens.has(optionalToken)) {
919+
i += optionalToken.length;
920+
continue;
921+
}
922+
const currentChar = literal[i];
923+
if (/\s/.test(currentChar)) {
924+
pattern += '\\s';
925+
} else {
926+
pattern += currentChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
927+
}
928+
i += 1;
929+
}
930+
pattern += '$';
931+
return pattern;
932+
}
863933
}

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -476,16 +476,20 @@ export class SelectQuerySqlite extends SelectQueryAbstract {
476476
}
477477

478478
// Logical Functions
479-
if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
480-
const wrapped = `(${condition})`;
479+
private truthinessScore(value: string): string {
480+
const wrapped = `(${value})`;
481481
const valueType = `TYPEOF${wrapped}`;
482-
const booleanCondition = `CASE
482+
return `CASE
483483
WHEN ${wrapped} IS NULL THEN 0
484484
WHEN ${valueType} = 'integer' OR ${valueType} = 'real' THEN (${wrapped}) != 0
485485
WHEN ${valueType} = 'text' THEN (${wrapped} != '' AND LOWER(${wrapped}) != 'null')
486486
ELSE (${wrapped}) IS NOT NULL AND ${wrapped} != 'null'
487487
END`;
488-
return `CASE WHEN (${booleanCondition}) THEN ${valueIfTrue} ELSE ${valueIfFalse} END`;
488+
}
489+
490+
if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
491+
const truthiness = this.truthinessScore(condition);
492+
return `CASE WHEN (${truthiness}) = 1 THEN ${valueIfTrue} ELSE ${valueIfFalse} END`;
489493
}
490494

491495
and(params: string[]): string {

0 commit comments

Comments
 (0)